mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +01:00
[PM-5189] Fix issues with inline menu rendering in iframes and SPA websites (#8431)
* [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 commit857008413f
. * Revert "[PM-5189] Working through content script port improvement" This reverts commitf219d71070
. * Revert "[PM-5189] Working through content script port improvement" This reverts commitf389263b64
. * Revert "[PM-5189] Working through content script port improvement" This reverts commit8a48e576e1
. * Revert "[PM-5189] Working through content script port improvement" This reverts commite30a1ebc5d
. * Revert "[PM-5189] Working through content script port improvement" This reverts commitda357f46b3
. * [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 commit62cf0f8f24
. * [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 <gsmith@bitwarden.com> * Fixed double code and added comment * Added changeDetection: ChangeDetectionStrategy.OnPush as per review --------- Co-authored-by: SmithThe4th <gsmith@bitwarden.com> * [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 commit78c2829793
. * 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 commit034713256c
. * feat: automatically convert save to remove * Revert "fix: default single user state saving undefined value to state" This reverts commit6c36da6ba5
. * [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 <djsmith85@users.noreply.github.com> * [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 <hello@addisonbeck.com> * [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 commitd68733b7e5
. * 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 <contact@willmartian.com> --------- Co-authored-by: Will Martin <contact@willmartian.com> * [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 <djsmith85@users.noreply.github.com> * [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 <anders@andersaberg.com> Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: SmithThe4th <gsmith@bitwarden.com> 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 <dani-garcia@users.noreply.github.com> Co-authored-by: ✨ Audrey ✨ <ajensen@bitwarden.com> Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Co-authored-by: Jake Fink <jfink@bitwarden.com> Co-authored-by: Addison Beck <github@addisonbeck.com> 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 <mgibson@bitwarden.com> Co-authored-by: Opeyemi <Alaoopeyemi101@gmail.com> Co-authored-by: Shane Melton <smelton@bitwarden.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com> Co-authored-by: Bernd Schoolmann <mail@quexten.com> Co-authored-by: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com> Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> Co-authored-by: Addison Beck <hello@addisonbeck.com> Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com> Co-authored-by: Will Martin <contact@willmartian.com> 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 <anders@andersaberg.com> Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: SmithThe4th <gsmith@bitwarden.com> 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 <dani-garcia@users.noreply.github.com> Co-authored-by: ✨ Audrey ✨ <ajensen@bitwarden.com> Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Co-authored-by: Jake Fink <jfink@bitwarden.com> Co-authored-by: Addison Beck <github@addisonbeck.com> 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 <mgibson@bitwarden.com> Co-authored-by: Opeyemi <Alaoopeyemi101@gmail.com> Co-authored-by: Shane Melton <smelton@bitwarden.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com> Co-authored-by: Bernd Schoolmann <mail@quexten.com> Co-authored-by: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com> Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> Co-authored-by: Addison Beck <hello@addisonbeck.com> Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com> Co-authored-by: Will Martin <contact@willmartian.com> 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>
This commit is contained in:
parent
7ed143dc62
commit
e973d72b01
@ -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<chrome.runtime.MessageSender["frameId"], PageDetail>
|
||||
>;
|
||||
|
||||
export type SubFrameOffsetData = {
|
||||
top: number;
|
||||
left: number;
|
||||
url?: string;
|
||||
frameId?: number;
|
||||
parentFrameIds?: number[];
|
||||
} | null;
|
||||
|
||||
export type SubFrameOffsetsForTab = Record<
|
||||
chrome.runtime.MessageSender["tab"]["id"],
|
||||
Map<chrome.runtime.MessageSender["frameId"], SubFrameOffsetData>
|
||||
>;
|
||||
|
||||
export type WebsiteIconData = {
|
||||
imageEnabled: boolean;
|
||||
image: string;
|
||||
fallbackImage: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
type OverlayAddNewItemMessage = {
|
||||
export type FocusedFieldData = {
|
||||
focusedFieldStyles: Partial<CSSStyleDeclaration>;
|
||||
focusedFieldRects: Partial<DOMRect>;
|
||||
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<CSSStyleDeclaration>;
|
||||
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<CSSStyleDeclaration>;
|
||||
focusedFieldRects: Partial<DOMRect>;
|
||||
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<void>;
|
||||
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<void>;
|
||||
removePageDetails(tabId: number): void;
|
||||
updateOverlayCiphers(): void;
|
||||
updateOverlayCiphers(): Promise<void>;
|
||||
}
|
||||
|
||||
export {
|
||||
WebsiteIconData,
|
||||
OverlayBackgroundExtensionMessage,
|
||||
OverlayPortMessage,
|
||||
FocusedFieldData,
|
||||
OverlayCipherData,
|
||||
OverlayAddNewItemMessage,
|
||||
OverlayBackgroundExtensionMessageHandlers,
|
||||
OverlayButtonPortMessageHandlers,
|
||||
OverlayListPortMessageHandlers,
|
||||
OverlayBackground,
|
||||
};
|
||||
|
@ -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)
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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<chrome.tabs.Tab>({
|
||||
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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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 };
|
||||
|
@ -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<AutofillInlineMenuContentService>;
|
||||
let autofillOverlayContentService: MockProxy<AutofillOverlayContentService>;
|
||||
let autofillInit: AutofillInit;
|
||||
const autofillOverlayContentService = mock<AutofillOverlayContentService>();
|
||||
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<AutofillInlineMenuContentService>();
|
||||
autofillOverlayContentService = mock<AutofillOverlayContentService>();
|
||||
autofillInit = new AutofillInit(autofillOverlayContentService, inlineMenuElements);
|
||||
sendExtensionMessageSpy = jest
|
||||
.spyOn(autofillInit as any, "sendExtensionMessage")
|
||||
.mockImplementation();
|
||||
window.IntersectionObserver = jest.fn(() => mock<IntersectionObserver>());
|
||||
});
|
||||
|
||||
@ -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<chrome.runtime.MessageSender>();
|
||||
});
|
||||
|
||||
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<AutofillScript>();
|
||||
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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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<CSSStyleDeclaration>;
|
||||
focusedFieldRects: Partial<DOMRect>;
|
||||
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,
|
||||
};
|
File diff suppressed because it is too large
Load Diff
@ -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<string, CipherView> = new Map();
|
||||
private pageDetailsForTab: Record<
|
||||
chrome.runtime.MessageSender["tab"]["id"],
|
||||
Map<chrome.runtime.MessageSender["frameId"], PageDetail>
|
||||
> = {};
|
||||
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<string, string>;
|
||||
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<OverlayCipherData[]> {
|
||||
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<InlineMenuVisibilitySetting> {
|
||||
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;
|
@ -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 };
|
@ -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<AutofillOverlayContentServiceDeprecated>();
|
||||
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<IntersectionObserver>());
|
||||
});
|
||||
|
||||
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<chrome.tabs.Tab>(),
|
||||
sender: "sender",
|
||||
};
|
||||
sender = mock<chrome.runtime.MessageSender>();
|
||||
});
|
||||
|
||||
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<AutofillScript>();
|
||||
|
||||
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<chrome.tabs.Tab>(),
|
||||
};
|
||||
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<AutofillScript>();
|
||||
jest.spyOn(autofillInit["insertAutofillContentService"], "fillForm").mockImplementation();
|
||||
});
|
||||
|
||||
it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", async () => {
|
||||
const fillScript = mock<AutofillScript>();
|
||||
const message = {
|
||||
command: "fillForm",
|
||||
fillScript,
|
||||
pageDetailsUrl: "https://a-different-url.com",
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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<AutofillPageDetails | void> {
|
||||
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;
|
@ -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);
|
@ -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 };
|
||||
|
@ -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;
|
||||
|
@ -15,7 +15,7 @@ exports[`AutofillOverlayIframeService initOverlayIframe sets up the iframe's att
|
||||
<iframe
|
||||
allowtransparency="true"
|
||||
sandbox="allow-scripts"
|
||||
src="chrome-extension://id/overlay/list.html"
|
||||
src="chrome-extension://id/overlay/legacy-list.html"
|
||||
style="all: initial !important; position: fixed !important; display: block !important; z-index: 2147483647 !important; line-height: 0 !important; overflow: hidden !important; transition: opacity 125ms ease-out 0s !important; visibility: visible !important; clip-path: none !important; pointer-events: auto !important; margin: 0px !important; padding: 0px !important; color-scheme: normal !important; opacity: 0 !important; height: 0px;"
|
||||
tabindex="-1"
|
||||
title="title"
|
@ -1,4 +1,4 @@
|
||||
import AutofillOverlayButtonIframe from "./autofill-overlay-button-iframe";
|
||||
import AutofillOverlayButtonIframe from "./autofill-overlay-button-iframe.deprecated";
|
||||
|
||||
describe("AutofillOverlayButtonIframe", () => {
|
||||
window.customElements.define(
|
@ -1,6 +1,6 @@
|
||||
import { AutofillOverlayPort } from "../../utils/autofill-overlay.enum";
|
||||
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
|
||||
|
||||
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element";
|
||||
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element.deprecated";
|
||||
|
||||
class AutofillOverlayButtonIframe extends AutofillOverlayIframeElement {
|
||||
constructor(element: HTMLElement) {
|
@ -1,7 +1,7 @@
|
||||
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element";
|
||||
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service";
|
||||
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element.deprecated";
|
||||
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service.deprecated";
|
||||
|
||||
jest.mock("./autofill-overlay-iframe.service");
|
||||
jest.mock("./autofill-overlay-iframe.service.deprecated");
|
||||
|
||||
describe("AutofillOverlayIframeElement", () => {
|
||||
window.customElements.define(
|
@ -1,4 +1,4 @@
|
||||
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service";
|
||||
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service.deprecated";
|
||||
|
||||
class AutofillOverlayIframeElement {
|
||||
constructor(
|
@ -3,18 +3,18 @@ import { mock } from "jest-mock-extended";
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { createPortSpyMock } from "../../spec/autofill-mocks";
|
||||
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
|
||||
import { createPortSpyMock } from "../../../spec/autofill-mocks";
|
||||
import {
|
||||
flushPromises,
|
||||
sendPortMessage,
|
||||
triggerPortOnDisconnectEvent,
|
||||
} from "../../spec/testing-utils";
|
||||
import { AutofillOverlayPort } from "../../utils/autofill-overlay.enum";
|
||||
} from "../../../spec/testing-utils";
|
||||
|
||||
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service";
|
||||
import AutofillOverlayIframeService from "./autofill-overlay-iframe.service.deprecated";
|
||||
|
||||
describe("AutofillOverlayIframeService", () => {
|
||||
const iframePath = "overlay/list.html";
|
||||
const iframePath = "overlay/legacy-list.html";
|
||||
let autofillOverlayIframeService: AutofillOverlayIframeService;
|
||||
let portSpy: chrome.runtime.Port;
|
||||
let shadowAppendSpy: jest.SpyInstance;
|
@ -1,13 +1,13 @@
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { setElementStyles } from "../../utils";
|
||||
import { setElementStyles } from "../../../utils";
|
||||
import {
|
||||
BackgroundPortMessageHandlers,
|
||||
AutofillOverlayIframeService as AutofillOverlayIframeServiceInterface,
|
||||
AutofillOverlayIframeExtensionMessage,
|
||||
AutofillOverlayIframeWindowMessageHandlers,
|
||||
} from "../abstractions/autofill-overlay-iframe.service";
|
||||
} from "../abstractions/autofill-overlay-iframe.service.deprecated";
|
||||
|
||||
class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterface {
|
||||
private port: chrome.runtime.Port | null = null;
|
@ -1,4 +1,4 @@
|
||||
import AutofillOverlayListIframe from "./autofill-overlay-list-iframe";
|
||||
import AutofillOverlayListIframe from "./autofill-overlay-list-iframe.deprecated";
|
||||
|
||||
describe("AutofillOverlayListIframe", () => {
|
||||
window.customElements.define(
|
@ -1,6 +1,6 @@
|
||||
import { AutofillOverlayPort } from "../../utils/autofill-overlay.enum";
|
||||
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
|
||||
|
||||
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element";
|
||||
import AutofillOverlayIframeElement from "./autofill-overlay-iframe-element.deprecated";
|
||||
|
||||
class AutofillOverlayListIframe extends AutofillOverlayIframeElement {
|
||||
constructor(element: HTMLElement) {
|
@ -1,9 +1,36 @@
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
import { createInitAutofillOverlayButtonMessageMock } from "../../../spec/autofill-mocks";
|
||||
import { postWindowMessage } from "../../../spec/testing-utils";
|
||||
import { postWindowMessage } from "../../../../spec/testing-utils";
|
||||
import { InitAutofillOverlayButtonMessage } from "../../abstractions/autofill-overlay-button.deprecated";
|
||||
|
||||
import AutofillOverlayButton from "./autofill-overlay-button";
|
||||
import AutofillOverlayButton from "./autofill-overlay-button.deprecated";
|
||||
|
||||
const overlayPagesTranslations = {
|
||||
locale: "en",
|
||||
buttonPageTitle: "buttonPageTitle",
|
||||
listPageTitle: "listPageTitle",
|
||||
opensInANewWindow: "opensInANewWindow",
|
||||
toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay",
|
||||
unlockYourAccount: "unlockYourAccount",
|
||||
unlockAccount: "unlockAccount",
|
||||
fillCredentialsFor: "fillCredentialsFor",
|
||||
partialUsername: "partialUsername",
|
||||
view: "view",
|
||||
noItemsToShow: "noItemsToShow",
|
||||
newItem: "newItem",
|
||||
addNewVaultItem: "addNewVaultItem",
|
||||
};
|
||||
function createInitAutofillOverlayButtonMessageMock(
|
||||
customFields = {},
|
||||
): InitAutofillOverlayButtonMessage {
|
||||
return {
|
||||
command: "initAutofillOverlayButton",
|
||||
translations: overlayPagesTranslations,
|
||||
styleSheetUrl: "https://jest-testing-website.com",
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
...customFields,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AutofillOverlayButton", () => {
|
||||
globalThis.customElements.define("autofill-overlay-button", AutofillOverlayButton);
|
@ -3,14 +3,14 @@ import "lit/polyfill-support.js";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import { buildSvgDomElement } from "../../../utils";
|
||||
import { logoIcon, logoLockedIcon } from "../../../utils/svg-icons";
|
||||
import { buildSvgDomElement } from "../../../../utils";
|
||||
import { logoIcon, logoLockedIcon } from "../../../../utils/svg-icons";
|
||||
import {
|
||||
InitAutofillOverlayButtonMessage,
|
||||
OverlayButtonMessage,
|
||||
OverlayButtonWindowMessageHandlers,
|
||||
} from "../../abstractions/autofill-overlay-button";
|
||||
import AutofillOverlayPageElement from "../shared/autofill-overlay-page-element";
|
||||
} from "../../abstractions/autofill-overlay-button.deprecated";
|
||||
import AutofillOverlayPageElement from "../shared/autofill-overlay-page-element.deprecated";
|
||||
|
||||
class AutofillOverlayButton extends AutofillOverlayPageElement {
|
||||
private authStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut;
|
@ -0,0 +1,9 @@
|
||||
import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum";
|
||||
|
||||
import AutofillOverlayButton from "./autofill-overlay-button.deprecated";
|
||||
|
||||
require("./legacy-button.scss");
|
||||
|
||||
(function () {
|
||||
globalThis.customElements.define(AutofillOverlayElement.Button, AutofillOverlayButton);
|
||||
})();
|
@ -7,6 +7,6 @@
|
||||
<meta name="color-scheme" content="normal" />
|
||||
</head>
|
||||
<body>
|
||||
<autofill-overlay-button></autofill-overlay-button>
|
||||
<autofill-inline-menu-button></autofill-inline-menu-button>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,36 @@
|
||||
@import "../../../../shared/styles/variables";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
min-width: 100vw;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
autofill-overlay-button {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.overlay-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
.overlay-button-svg-icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
@ -1,11 +1,71 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { createInitAutofillOverlayListMessageMock } from "../../../spec/autofill-mocks";
|
||||
import { postWindowMessage } from "../../../spec/testing-utils";
|
||||
import { createAutofillOverlayCipherDataMock } from "../../../../spec/autofill-mocks";
|
||||
import { postWindowMessage } from "../../../../spec/testing-utils";
|
||||
import { InitAutofillOverlayListMessage } from "../../abstractions/autofill-overlay-list.deprecated";
|
||||
|
||||
import AutofillOverlayList from "./autofill-overlay-list";
|
||||
import AutofillOverlayList from "./autofill-overlay-list.deprecated";
|
||||
|
||||
const overlayPagesTranslations = {
|
||||
locale: "en",
|
||||
buttonPageTitle: "buttonPageTitle",
|
||||
listPageTitle: "listPageTitle",
|
||||
opensInANewWindow: "opensInANewWindow",
|
||||
toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay",
|
||||
unlockYourAccount: "unlockYourAccount",
|
||||
unlockAccount: "unlockAccount",
|
||||
fillCredentialsFor: "fillCredentialsFor",
|
||||
partialUsername: "partialUsername",
|
||||
view: "view",
|
||||
noItemsToShow: "noItemsToShow",
|
||||
newItem: "newItem",
|
||||
addNewVaultItem: "addNewVaultItem",
|
||||
};
|
||||
function createInitAutofillOverlayListMessageMock(
|
||||
customFields = {},
|
||||
): InitAutofillOverlayListMessage {
|
||||
return {
|
||||
command: "initAutofillOverlayList",
|
||||
translations: overlayPagesTranslations,
|
||||
styleSheetUrl: "https://jest-testing-website.com",
|
||||
theme: ThemeType.Light,
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
ciphers: [
|
||||
createAutofillOverlayCipherDataMock(1, {
|
||||
icon: {
|
||||
imageEnabled: true,
|
||||
image: "https://jest-testing-website.com/image.png",
|
||||
fallbackImage: "",
|
||||
icon: "bw-icon",
|
||||
},
|
||||
}),
|
||||
createAutofillOverlayCipherDataMock(2, {
|
||||
icon: {
|
||||
imageEnabled: true,
|
||||
image: "",
|
||||
fallbackImage: "https://jest-testing-website.com/fallback.png",
|
||||
icon: "bw-icon",
|
||||
},
|
||||
}),
|
||||
createAutofillOverlayCipherDataMock(3, {
|
||||
name: "",
|
||||
login: { username: "" },
|
||||
icon: { imageEnabled: true, image: "", fallbackImage: "", icon: "bw-icon" },
|
||||
}),
|
||||
createAutofillOverlayCipherDataMock(4, {
|
||||
icon: { imageEnabled: false, image: "", fallbackImage: "", icon: "" },
|
||||
}),
|
||||
createAutofillOverlayCipherDataMock(5),
|
||||
createAutofillOverlayCipherDataMock(6),
|
||||
createAutofillOverlayCipherDataMock(7),
|
||||
createAutofillOverlayCipherDataMock(8),
|
||||
],
|
||||
...customFields,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AutofillOverlayList", () => {
|
||||
globalThis.customElements.define("autofill-overlay-list", AutofillOverlayList);
|
@ -3,14 +3,14 @@ import "lit/polyfill-support.js";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import { OverlayCipherData } from "../../../background/abstractions/overlay.background";
|
||||
import { buildSvgDomElement } from "../../../utils";
|
||||
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../utils/svg-icons";
|
||||
import { buildSvgDomElement } from "../../../../utils";
|
||||
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../../utils/svg-icons";
|
||||
import { OverlayCipherData } from "../../../background/abstractions/overlay.background.deprecated";
|
||||
import {
|
||||
InitAutofillOverlayListMessage,
|
||||
OverlayListWindowMessageHandlers,
|
||||
} from "../../abstractions/autofill-overlay-list";
|
||||
import AutofillOverlayPageElement from "../shared/autofill-overlay-page-element";
|
||||
} from "../../abstractions/autofill-overlay-list.deprecated";
|
||||
import AutofillOverlayPageElement from "../shared/autofill-overlay-page-element.deprecated";
|
||||
|
||||
class AutofillOverlayList extends AutofillOverlayPageElement {
|
||||
private overlayListContainer: HTMLDivElement;
|
||||
@ -52,7 +52,7 @@ class AutofillOverlayList extends AutofillOverlayPageElement {
|
||||
authStatus,
|
||||
ciphers,
|
||||
}: InitAutofillOverlayListMessage) {
|
||||
const linkElement = this.initOverlayPage("button", styleSheetUrl, translations);
|
||||
const linkElement = this.initOverlayPage("list", styleSheetUrl, translations);
|
||||
|
||||
const themeClass = `theme_${theme}`;
|
||||
globalThis.document.documentElement.classList.add(themeClass);
|
@ -0,0 +1,9 @@
|
||||
import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum";
|
||||
|
||||
import AutofillOverlayList from "./autofill-overlay-list.deprecated";
|
||||
|
||||
require("./legacy-list.scss");
|
||||
|
||||
(function () {
|
||||
globalThis.customElements.define(AutofillOverlayElement.List, AutofillOverlayList);
|
||||
})();
|
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Bitwarden vault</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="normal" />
|
||||
</head>
|
||||
<body>
|
||||
<autofill-inline-menu-list></autofill-inline-menu-list>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,293 @@
|
||||
@import "../../../../../../../../libs/angular/src/scss/webfonts.css";
|
||||
@import "../../../../../../../../libs/angular/src/scss/bwicons/styles/style";
|
||||
@import "../../../../shared/styles/variables";
|
||||
@import "../../../../../../../../libs/angular/src/scss/icons";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
@include themify($themes) {
|
||||
color: themed("textColor");
|
||||
background-color: themed("backgroundColor");
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-list-message {
|
||||
font-family: $font-family-sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.5;
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
|
||||
@include themify($themes) {
|
||||
color: themed("textColor");
|
||||
}
|
||||
|
||||
&.no-items {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-list-button-container {
|
||||
width: 100%;
|
||||
padding: 0.2rem;
|
||||
background: transparent;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
border-top-width: 0.1rem;
|
||||
border-top-style: solid;
|
||||
|
||||
@include themify($themes) {
|
||||
border-top-color: themed("borderColor");
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@include themify($themes) {
|
||||
background: themed("backgroundOffsetColor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-list-button {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
font-family: $font-family-sans-serif;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.7rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 0.4rem;
|
||||
|
||||
@include themify($themes) {
|
||||
color: themed("primaryColor");
|
||||
}
|
||||
|
||||
&:focus:focus-visible {
|
||||
outline-width: 0.2rem;
|
||||
outline-style: solid;
|
||||
|
||||
@include themify($themes) {
|
||||
outline-color: themed("focusOutlineColor");
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
margin-left: 0.4rem;
|
||||
margin-right: 0.8rem;
|
||||
|
||||
path {
|
||||
@include themify($themes) {
|
||||
fill: themed("primaryColor") !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unlock-button {
|
||||
svg {
|
||||
top: 0.2rem;
|
||||
width: 1.6rem;
|
||||
height: 1.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.add-new-item-button {
|
||||
svg {
|
||||
top: 0.2rem;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-actions-list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.overlay-actions-list-item {
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
list-style: none;
|
||||
padding: 0.2rem;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom-width: 0.1rem;
|
||||
border-bottom-style: solid;
|
||||
|
||||
@include themify($themes) {
|
||||
border-bottom-color: themed("borderColor");
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@include themify($themes) {
|
||||
background: themed("backgroundOffsetColor");
|
||||
}
|
||||
}
|
||||
|
||||
.cipher-container {
|
||||
display: flex;
|
||||
align-content: flex-start;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.7rem 0.3rem 0.7rem 0.7rem;
|
||||
border-radius: 0.4rem;
|
||||
|
||||
&:focus-within:not(.remove-outline) {
|
||||
outline-width: 0.2rem;
|
||||
outline-style: solid;
|
||||
|
||||
@include themify($themes) {
|
||||
outline-color: themed("focusOutlineColor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fill-cipher-button,
|
||||
.view-cipher-button {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
line-height: 0;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fill-cipher-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
justify-content: flex-start;
|
||||
width: calc(100% - 4rem);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.view-cipher-button {
|
||||
flex-shrink: 0;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.4rem;
|
||||
|
||||
&:focus:focus-visible {
|
||||
outline-width: 0.2rem;
|
||||
outline-style: solid;
|
||||
|
||||
@include themify($themes) {
|
||||
outline-color: themed("focusOutlineColor");
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
path {
|
||||
@include themify($themes) {
|
||||
fill: themed("primaryColor") !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cipher-icon {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 3.2rem;
|
||||
height: 3.2rem;
|
||||
margin: 0 1rem 0 0;
|
||||
line-height: 0;
|
||||
background-size: 2.6rem;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
@include themify($themes) {
|
||||
color: themed("mutedTextColor");
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-shrink: 0;
|
||||
|
||||
path {
|
||||
@include themify($themes) {
|
||||
fill: themed("primaryColor") !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.bwi {
|
||||
font-size: 2.6rem;
|
||||
|
||||
&:not(.cipher-icon) {
|
||||
@include themify($themes) {
|
||||
color: themed("primaryColor");
|
||||
}
|
||||
|
||||
svg {
|
||||
path {
|
||||
@include themify($themes) {
|
||||
fill: themed("primaryColor") !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cipher-details {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cipher-name,
|
||||
.cipher-user-login {
|
||||
display: block;
|
||||
width: 100%;
|
||||
line-height: 1.5;
|
||||
font-family: $font-family-sans-serif;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cipher-name {
|
||||
font-size: 1.6rem;
|
||||
|
||||
@include themify($themes) {
|
||||
color: themed("textColor");
|
||||
}
|
||||
}
|
||||
|
||||
.cipher-user-login {
|
||||
font-size: 1.4rem;
|
||||
|
||||
@include themify($themes) {
|
||||
color: themed("mutedTextColor");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,12 +1,15 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { OverlayButtonWindowMessageHandlers } from "../../abstractions/autofill-overlay-button";
|
||||
import { OverlayButtonWindowMessageHandlers } from "../../abstractions/autofill-overlay-button.deprecated";
|
||||
|
||||
import AutofillOverlayPageElement from "./autofill-overlay-page-element";
|
||||
import AutofillOverlayPageElementDeprecated from "./autofill-overlay-page-element.deprecated";
|
||||
|
||||
describe("AutofillOverlayPageElement", () => {
|
||||
globalThis.customElements.define("autofill-overlay-page-element", AutofillOverlayPageElement);
|
||||
let autofillOverlayPageElement: AutofillOverlayPageElement;
|
||||
globalThis.customElements.define(
|
||||
"autofill-overlay-page-element",
|
||||
AutofillOverlayPageElementDeprecated,
|
||||
);
|
||||
let autofillOverlayPageElement: AutofillOverlayPageElementDeprecated;
|
||||
const translations = {
|
||||
locale: "en",
|
||||
buttonPageTitle: "buttonPageTitle",
|
@ -1,10 +1,10 @@
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import { RedirectFocusDirection } from "../../../utils/autofill-overlay.enum";
|
||||
import { RedirectFocusDirection } from "../../../../enums/autofill-overlay.enum";
|
||||
import {
|
||||
AutofillOverlayPageElementWindowMessage,
|
||||
WindowMessageHandlers,
|
||||
} from "../../abstractions/autofill-overlay-page-element";
|
||||
} from "../../abstractions/autofill-overlay-page-element.deprecated";
|
||||
|
||||
class AutofillOverlayPageElement extends HTMLElement {
|
||||
protected shadowDom: ShadowRoot;
|
@ -0,0 +1,37 @@
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
import AutofillField from "../../../models/autofill-field";
|
||||
import AutofillPageDetails from "../../../models/autofill-page-details";
|
||||
import { AutofillOverlayContentService } from "../../../services/abstractions/autofill-overlay-content.service";
|
||||
import { ElementWithOpId, FormFieldElement } from "../../../types";
|
||||
|
||||
type OpenAutofillOverlayOptions = {
|
||||
isFocusingFieldElement?: boolean;
|
||||
isOpeningFullOverlay?: boolean;
|
||||
authStatus?: AuthenticationStatus;
|
||||
};
|
||||
|
||||
interface LegacyAutofillOverlayContentService extends AutofillOverlayContentService {
|
||||
isFieldCurrentlyFocused: boolean;
|
||||
isCurrentlyFilling: boolean;
|
||||
isOverlayCiphersPopulated: boolean;
|
||||
pageDetailsUpdateRequired: boolean;
|
||||
autofillOverlayVisibility: number;
|
||||
init(): void;
|
||||
setupAutofillOverlayListenerOnField(
|
||||
autofillFieldElement: ElementWithOpId<FormFieldElement>,
|
||||
autofillFieldData: AutofillField,
|
||||
pageDetails: AutofillPageDetails,
|
||||
): Promise<void>;
|
||||
openAutofillOverlay(options: OpenAutofillOverlayOptions): void;
|
||||
removeAutofillOverlay(): void;
|
||||
removeAutofillOverlayButton(): void;
|
||||
removeAutofillOverlayList(): void;
|
||||
addNewVaultItem(): void;
|
||||
redirectOverlayFocusOut(direction: "previous" | "next"): void;
|
||||
focusMostRecentOverlayField(): void;
|
||||
blurMostRecentOverlayField(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export { OpenAutofillOverlayOptions, LegacyAutofillOverlayContentService };
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
22
apps/browser/src/autofill/enums/autofill-overlay.enum.ts
Normal file
22
apps/browser/src/autofill/enums/autofill-overlay.enum.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export const AutofillOverlayElement = {
|
||||
Button: "autofill-inline-menu-button",
|
||||
List: "autofill-inline-menu-list",
|
||||
} as const;
|
||||
|
||||
export type AutofillOverlayElementType =
|
||||
(typeof AutofillOverlayElement)[keyof typeof AutofillOverlayElement];
|
||||
|
||||
export const AutofillOverlayPort = {
|
||||
Button: "autofill-inline-menu-button-port",
|
||||
ButtonMessageConnector: "autofill-inline-menu-button-message-connector",
|
||||
List: "autofill-inline-menu-list-port",
|
||||
ListMessageConnector: "autofill-inline-menu-list-message-connector",
|
||||
} as const;
|
||||
|
||||
export const RedirectFocusDirection = {
|
||||
Current: "current",
|
||||
Previous: "previous",
|
||||
Next: "next",
|
||||
} as const;
|
||||
|
||||
export const MAX_SUB_FRAME_DEPTH = 8;
|
@ -295,12 +295,12 @@ export class Fido2Background implements Fido2BackgroundInterface {
|
||||
) => {
|
||||
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)
|
||||
|
@ -0,0 +1,33 @@
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
export type AutofillInlineMenuButtonMessage = { command: string; colorScheme?: string };
|
||||
|
||||
export type UpdateAuthStatusMessage = AutofillInlineMenuButtonMessage & {
|
||||
authStatus: AuthenticationStatus;
|
||||
};
|
||||
|
||||
export type InitAutofillInlineMenuButtonMessage = UpdateAuthStatusMessage & {
|
||||
styleSheetUrl: string;
|
||||
translations: Record<string, string>;
|
||||
portKey: string;
|
||||
};
|
||||
|
||||
export type AutofillInlineMenuButtonWindowMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
initAutofillInlineMenuButton: ({
|
||||
message,
|
||||
}: {
|
||||
message: InitAutofillInlineMenuButtonMessage;
|
||||
}) => void;
|
||||
checkAutofillInlineMenuButtonFocused: () => void;
|
||||
updateAutofillInlineMenuButtonAuthStatus: ({
|
||||
message,
|
||||
}: {
|
||||
message: UpdateAuthStatusMessage;
|
||||
}) => void;
|
||||
updateAutofillInlineMenuColorScheme: ({
|
||||
message,
|
||||
}: {
|
||||
message: AutofillInlineMenuButtonMessage;
|
||||
}) => void;
|
||||
};
|
@ -0,0 +1,31 @@
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
import { InlineMenuCipherData } from "../../../background/abstractions/overlay.background";
|
||||
|
||||
export type AutofillInlineMenuContainerMessage = {
|
||||
command: string;
|
||||
portKey: string;
|
||||
};
|
||||
|
||||
export type InitAutofillInlineMenuElementMessage = AutofillInlineMenuContainerMessage & {
|
||||
iframeUrl: string;
|
||||
pageTitle: string;
|
||||
authStatus: AuthenticationStatus;
|
||||
styleSheetUrl: string;
|
||||
theme: string;
|
||||
translations: Record<string, string>;
|
||||
ciphers: InlineMenuCipherData[] | null;
|
||||
portName: string;
|
||||
};
|
||||
|
||||
export type AutofillInlineMenuContainerWindowMessage = AutofillInlineMenuContainerMessage &
|
||||
Record<string, unknown>;
|
||||
|
||||
export type AutofillInlineMenuContainerPortMessage = AutofillInlineMenuContainerMessage &
|
||||
Record<string, unknown>;
|
||||
|
||||
export type AutofillInlineMenuContainerWindowMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
initAutofillInlineMenuButton: (message: InitAutofillInlineMenuElementMessage) => void;
|
||||
initAutofillInlineMenuList: (message: InitAutofillInlineMenuElementMessage) => void;
|
||||
};
|
@ -0,0 +1,13 @@
|
||||
import { AutofillExtensionMessageParam } from "../../../content/abstractions/autofill-init";
|
||||
|
||||
export type InlineMenuExtensionMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
closeAutofillInlineMenu: ({ message }: AutofillExtensionMessageParam) => void;
|
||||
appendAutofillInlineMenuToDom: ({ message }: AutofillExtensionMessageParam) => Promise<void>;
|
||||
};
|
||||
|
||||
export interface AutofillInlineMenuContentService {
|
||||
messageHandlers: InlineMenuExtensionMessageHandlers;
|
||||
isElementInlineMenu(element: HTMLElement): boolean;
|
||||
destroy(): void;
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
export type AutofillInlineMenuIframeExtensionMessage = {
|
||||
command: string;
|
||||
styles?: Partial<CSSStyleDeclaration>;
|
||||
theme?: string;
|
||||
portKey?: string;
|
||||
};
|
||||
|
||||
export type AutofillInlineMenuIframeExtensionMessageParam = {
|
||||
message: AutofillInlineMenuIframeExtensionMessage;
|
||||
};
|
||||
|
||||
export type BackgroundPortMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
initAutofillInlineMenuButton: ({
|
||||
message,
|
||||
}: AutofillInlineMenuIframeExtensionMessageParam) => void;
|
||||
initAutofillInlineMenuList: ({ message }: AutofillInlineMenuIframeExtensionMessageParam) => void;
|
||||
updateAutofillInlineMenuPosition: ({
|
||||
message,
|
||||
}: AutofillInlineMenuIframeExtensionMessageParam) => void;
|
||||
toggleAutofillInlineMenuHidden: ({
|
||||
message,
|
||||
}: AutofillInlineMenuIframeExtensionMessageParam) => void;
|
||||
updateAutofillInlineMenuColorScheme: () => void;
|
||||
fadeInAutofillInlineMenuIframe: () => void;
|
||||
};
|
||||
|
||||
export interface AutofillInlineMenuIframeService {
|
||||
initMenuIframe(): void;
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
import { InlineMenuCipherData } from "../../../background/abstractions/overlay.background";
|
||||
|
||||
type AutofillInlineMenuListMessage = { command: string };
|
||||
|
||||
export type UpdateAutofillInlineMenuListCiphersMessage = AutofillInlineMenuListMessage & {
|
||||
ciphers: InlineMenuCipherData[];
|
||||
};
|
||||
|
||||
export type InitAutofillInlineMenuListMessage = AutofillInlineMenuListMessage & {
|
||||
authStatus: AuthenticationStatus;
|
||||
styleSheetUrl: string;
|
||||
theme: string;
|
||||
translations: Record<string, string>;
|
||||
ciphers?: InlineMenuCipherData[];
|
||||
portKey: string;
|
||||
};
|
||||
|
||||
export type AutofillInlineMenuListWindowMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
initAutofillInlineMenuList: ({ message }: { message: InitAutofillInlineMenuListMessage }) => void;
|
||||
checkAutofillInlineMenuListFocused: () => void;
|
||||
updateAutofillInlineMenuListCiphers: ({
|
||||
message,
|
||||
}: {
|
||||
message: UpdateAutofillInlineMenuListCiphersMessage;
|
||||
}) => void;
|
||||
focusAutofillInlineMenuList: () => void;
|
||||
};
|
@ -0,0 +1,13 @@
|
||||
import { AutofillInlineMenuButtonWindowMessageHandlers } from "./autofill-inline-menu-button";
|
||||
import { AutofillInlineMenuListWindowMessageHandlers } from "./autofill-inline-menu-list";
|
||||
|
||||
export type AutofillInlineMenuPageElementWindowMessageHandlers =
|
||||
| AutofillInlineMenuButtonWindowMessageHandlers
|
||||
| AutofillInlineMenuListWindowMessageHandlers;
|
||||
|
||||
export type AutofillInlineMenuPageElementWindowMessage = {
|
||||
[key: string]: any;
|
||||
command: string;
|
||||
inlineMenuCipherId?: string;
|
||||
height?: number;
|
||||
};
|
@ -0,0 +1,426 @@
|
||||
import AutofillInit from "../../../content/autofill-init";
|
||||
import { AutofillOverlayElement } from "../../../enums/autofill-overlay.enum";
|
||||
import { createMutationRecordMock } from "../../../spec/autofill-mocks";
|
||||
import { flushPromises, sendMockExtensionMessage } from "../../../spec/testing-utils";
|
||||
import { ElementWithOpId } from "../../../types";
|
||||
|
||||
import { AutofillInlineMenuContentService } from "./autofill-inline-menu-content.service";
|
||||
|
||||
describe("AutofillInlineMenuContentService", () => {
|
||||
let autofillInlineMenuContentService: AutofillInlineMenuContentService;
|
||||
let autofillInit: AutofillInit;
|
||||
let sendExtensionMessageSpy: jest.SpyInstance;
|
||||
let observeBodyMutationsSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.document.body.innerHTML = "";
|
||||
autofillInlineMenuContentService = new AutofillInlineMenuContentService();
|
||||
autofillInit = new AutofillInit(null, autofillInlineMenuContentService);
|
||||
autofillInit.init();
|
||||
observeBodyMutationsSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["bodyElementMutationObserver"] as any,
|
||||
"observe",
|
||||
);
|
||||
sendExtensionMessageSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"sendExtensionMessage",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("isElementInlineMenu", () => {
|
||||
it("returns true if the passed element is the inline menu", () => {
|
||||
const element = document.createElement("div");
|
||||
autofillInlineMenuContentService["listElement"] = element;
|
||||
|
||||
expect(autofillInlineMenuContentService.isElementInlineMenu(element)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extension message handlers", () => {
|
||||
describe("closeAutofillInlineMenu message handler", () => {
|
||||
beforeEach(() => {
|
||||
observeBodyMutationsSpy.mockImplementation();
|
||||
});
|
||||
|
||||
it("closes the inline menu button", async () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "closeAutofillInlineMenu",
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", {
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the inline menu list", async () => {
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "closeAutofillInlineMenu",
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", {
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes both inline menu elements and removes the body element mutation observer", async () => {
|
||||
const unobserveBodyElementSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"unobserveBodyElement",
|
||||
);
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "closeAutofillInlineMenu",
|
||||
});
|
||||
|
||||
expect(unobserveBodyElementSpy).toHaveBeenCalled();
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", {
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", {
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendAutofillInlineMenuToDom message handler", () => {
|
||||
let isInlineMenuButtonVisibleSpy: jest.SpyInstance;
|
||||
let isInlineMenuListVisibleSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
isInlineMenuButtonVisibleSpy = jest
|
||||
.spyOn(autofillInlineMenuContentService as any, "isInlineMenuButtonVisible")
|
||||
.mockResolvedValue(true);
|
||||
isInlineMenuListVisibleSpy = jest
|
||||
.spyOn(autofillInlineMenuContentService as any, "isInlineMenuListVisible")
|
||||
.mockResolvedValue(true);
|
||||
jest.spyOn(globalThis.document.body, "appendChild");
|
||||
observeBodyMutationsSpy.mockImplementation();
|
||||
});
|
||||
|
||||
describe("creating the inline menu button", () => {
|
||||
it("creates a `div` button element if the user browser is Firefox", () => {
|
||||
autofillInlineMenuContentService["isFirefoxBrowser"] = true;
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
|
||||
expect(autofillInlineMenuContentService["buttonElement"]).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it("appends the inline menu button to the DOM if the button is not visible", async () => {
|
||||
isInlineMenuButtonVisibleSpy.mockResolvedValue(false);
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.document.body.appendChild).toHaveBeenCalledWith(
|
||||
autofillInlineMenuContentService["buttonElement"],
|
||||
);
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
|
||||
"updateAutofillInlineMenuElementIsVisibleStatus",
|
||||
{
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
isVisible: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("creating the inline menu list", () => {
|
||||
it("creates a `div` list element if the user browser is Firefox", () => {
|
||||
autofillInlineMenuContentService["isFirefoxBrowser"] = true;
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
|
||||
expect(autofillInlineMenuContentService["listElement"]).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it("appends the inline menu list to the DOM if the button is not visible", async () => {
|
||||
isInlineMenuListVisibleSpy.mockResolvedValue(false);
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "appendAutofillInlineMenuToDom",
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.document.body.appendChild).toHaveBeenCalledWith(
|
||||
autofillInlineMenuContentService["listElement"],
|
||||
);
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith(
|
||||
"updateAutofillInlineMenuElementIsVisibleStatus",
|
||||
{
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
isVisible: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleInlineMenuElementMutationObserverUpdate", () => {
|
||||
let usernameField: ElementWithOpId<HTMLInputElement>;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<form id="validFormId">
|
||||
<input type="text" id="username-field" placeholder="username" />
|
||||
<input type="password" id="password-field" placeholder="password" />
|
||||
</form>
|
||||
`;
|
||||
usernameField = document.getElementById(
|
||||
"username-field",
|
||||
) as ElementWithOpId<HTMLInputElement>;
|
||||
usernameField.style.setProperty("display", "block", "important");
|
||||
jest.spyOn(usernameField, "removeAttribute");
|
||||
jest.spyOn(usernameField.style, "setProperty");
|
||||
jest
|
||||
.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"isTriggeringExcessiveMutationObserverIterations",
|
||||
)
|
||||
.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("skips handling the mutation if excessive mutation observer events are triggered", () => {
|
||||
jest
|
||||
.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"isTriggeringExcessiveMutationObserverIterations",
|
||||
)
|
||||
.mockReturnValue(true);
|
||||
|
||||
autofillInlineMenuContentService["handleInlineMenuElementMutationObserverUpdate"]([
|
||||
createMutationRecordMock({ target: usernameField }),
|
||||
]);
|
||||
|
||||
expect(usernameField.removeAttribute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips handling the mutation if the record type is not for `attributes`", () => {
|
||||
autofillInlineMenuContentService["handleInlineMenuElementMutationObserverUpdate"]([
|
||||
createMutationRecordMock({ target: usernameField, type: "childList" }),
|
||||
]);
|
||||
|
||||
expect(usernameField.removeAttribute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes all element attributes that are not the style attribute", () => {
|
||||
autofillInlineMenuContentService["handleInlineMenuElementMutationObserverUpdate"]([
|
||||
createMutationRecordMock({
|
||||
target: usernameField,
|
||||
type: "attributes",
|
||||
attributeName: "placeholder",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(usernameField.removeAttribute).toHaveBeenCalledWith("placeholder");
|
||||
});
|
||||
|
||||
it("removes all attached style attributes and sets the default styles", () => {
|
||||
autofillInlineMenuContentService["handleInlineMenuElementMutationObserverUpdate"]([
|
||||
createMutationRecordMock({
|
||||
target: usernameField,
|
||||
type: "attributes",
|
||||
attributeName: "style",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(usernameField.removeAttribute).toHaveBeenCalledWith("style");
|
||||
expect(usernameField.style.setProperty).toHaveBeenCalledWith("all", "initial", "important");
|
||||
expect(usernameField.style.setProperty).toHaveBeenCalledWith(
|
||||
"position",
|
||||
"fixed",
|
||||
"important",
|
||||
);
|
||||
expect(usernameField.style.setProperty).toHaveBeenCalledWith("display", "block", "important");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleBodyElementMutationObserverUpdate", () => {
|
||||
let buttonElement: HTMLElement;
|
||||
let listElement: HTMLElement;
|
||||
let isInlineMenuListVisibleSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div class="overlay-button"></div>
|
||||
<div class="overlay-list"></div>
|
||||
`;
|
||||
buttonElement = document.querySelector(".overlay-button") as HTMLElement;
|
||||
listElement = document.querySelector(".overlay-list") as HTMLElement;
|
||||
autofillInlineMenuContentService["buttonElement"] = buttonElement;
|
||||
autofillInlineMenuContentService["listElement"] = listElement;
|
||||
isInlineMenuListVisibleSpy = jest
|
||||
.spyOn(autofillInlineMenuContentService as any, "isInlineMenuListVisible")
|
||||
.mockResolvedValue(true);
|
||||
jest.spyOn(globalThis.document.body, "insertBefore");
|
||||
jest
|
||||
.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"isTriggeringExcessiveMutationObserverIterations",
|
||||
)
|
||||
.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("skips handling the mutation if the overlay elements are not present in the DOM", async () => {
|
||||
autofillInlineMenuContentService["buttonElement"] = undefined;
|
||||
autofillInlineMenuContentService["listElement"] = undefined;
|
||||
|
||||
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"]();
|
||||
|
||||
expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips handling the mutation if excessive mutations are being triggered", async () => {
|
||||
jest
|
||||
.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"isTriggeringExcessiveMutationObserverIterations",
|
||||
)
|
||||
.mockReturnValue(true);
|
||||
|
||||
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"]();
|
||||
|
||||
expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips re-arranging the DOM elements if the last child of the body is the overlay list and the second to last child of the body is the overlay button", async () => {
|
||||
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"]();
|
||||
|
||||
expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips re-arranging the DOM elements if the last child is the overlay button and the overlay list is not visible", async () => {
|
||||
listElement.remove();
|
||||
isInlineMenuListVisibleSpy.mockResolvedValue(false);
|
||||
|
||||
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"]();
|
||||
|
||||
expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("positions the overlay button before the overlay list if an element has inserted itself after the button element", async () => {
|
||||
const injectedElement = document.createElement("div");
|
||||
document.body.insertBefore(injectedElement, listElement);
|
||||
|
||||
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"]();
|
||||
|
||||
expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith(
|
||||
buttonElement,
|
||||
listElement,
|
||||
);
|
||||
});
|
||||
|
||||
it("positions the overlay button before the overlay list if the elements have inserted in incorrect order", async () => {
|
||||
document.body.appendChild(buttonElement);
|
||||
|
||||
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"]();
|
||||
|
||||
expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith(
|
||||
buttonElement,
|
||||
listElement,
|
||||
);
|
||||
});
|
||||
|
||||
it("positions the last child before the overlay button if it is not the overlay list", async () => {
|
||||
const injectedElement = document.createElement("div");
|
||||
document.body.appendChild(injectedElement);
|
||||
|
||||
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"]();
|
||||
|
||||
expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith(
|
||||
injectedElement,
|
||||
buttonElement,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTriggeringExcessiveMutationObserverIterations", () => {
|
||||
it("clears any existing reset timeout", () => {
|
||||
jest.useFakeTimers();
|
||||
const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuContentService["mutationObserverIterationsResetTimeout"] = setTimeout(
|
||||
jest.fn(),
|
||||
123,
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["isTriggeringExcessiveMutationObserverIterations"]();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
it("will reset the number of mutationObserverIterations after two seconds", () => {
|
||||
jest.useFakeTimers();
|
||||
autofillInlineMenuContentService["mutationObserverIterations"] = 10;
|
||||
|
||||
autofillInlineMenuContentService["isTriggeringExcessiveMutationObserverIterations"]();
|
||||
jest.advanceTimersByTime(2000);
|
||||
|
||||
expect(autofillInlineMenuContentService["mutationObserverIterations"]).toEqual(0);
|
||||
});
|
||||
|
||||
it("will blur the overlay field and remove the autofill overlay if excessive mutation observer iterations are triggering", async () => {
|
||||
autofillInlineMenuContentService["mutationObserverIterations"] = 101;
|
||||
const closeInlineMenuSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"closeInlineMenu",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["isTriggeringExcessiveMutationObserverIterations"]();
|
||||
await flushPromises();
|
||||
|
||||
expect(closeInlineMenuSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
it("closes the inline menu", () => {
|
||||
autofillInlineMenuContentService["buttonElement"] = document.createElement("div");
|
||||
autofillInlineMenuContentService["listElement"] = document.createElement("div");
|
||||
|
||||
autofillInlineMenuContentService.destroy();
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", {
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", {
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,437 @@
|
||||
import { AutofillExtensionMessage } from "../../../content/abstractions/autofill-init";
|
||||
import {
|
||||
AutofillOverlayElement,
|
||||
AutofillOverlayElementType,
|
||||
} from "../../../enums/autofill-overlay.enum";
|
||||
import {
|
||||
sendExtensionMessage,
|
||||
generateRandomCustomElementName,
|
||||
setElementStyles,
|
||||
} from "../../../utils";
|
||||
import {
|
||||
InlineMenuExtensionMessageHandlers,
|
||||
AutofillInlineMenuContentService as AutofillInlineMenuContentServiceInterface,
|
||||
} from "../abstractions/autofill-inline-menu-content.service";
|
||||
import { AutofillInlineMenuButtonIframe } from "../iframe-content/autofill-inline-menu-button-iframe";
|
||||
import { AutofillInlineMenuListIframe } from "../iframe-content/autofill-inline-menu-list-iframe";
|
||||
|
||||
export class AutofillInlineMenuContentService implements AutofillInlineMenuContentServiceInterface {
|
||||
private readonly sendExtensionMessage = sendExtensionMessage;
|
||||
private readonly generateRandomCustomElementName = generateRandomCustomElementName;
|
||||
private readonly setElementStyles = setElementStyles;
|
||||
private isFirefoxBrowser =
|
||||
globalThis.navigator.userAgent.indexOf(" Firefox/") !== -1 ||
|
||||
globalThis.navigator.userAgent.indexOf(" Gecko/") !== -1;
|
||||
private buttonElement: HTMLElement;
|
||||
private listElement: HTMLElement;
|
||||
private inlineMenuElementsMutationObserver: MutationObserver;
|
||||
private bodyElementMutationObserver: MutationObserver;
|
||||
private mutationObserverIterations = 0;
|
||||
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout;
|
||||
private readonly customElementDefaultStyles: Partial<CSSStyleDeclaration> = {
|
||||
all: "initial",
|
||||
position: "fixed",
|
||||
display: "block",
|
||||
zIndex: "2147483647",
|
||||
};
|
||||
private readonly extensionMessageHandlers: InlineMenuExtensionMessageHandlers = {
|
||||
closeAutofillInlineMenu: ({ message }) => this.closeInlineMenu(message),
|
||||
appendAutofillInlineMenuToDom: ({ message }) => this.appendInlineMenuElements(message),
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.setupMutationObserver();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message handlers for the autofill inline menu content service.
|
||||
*/
|
||||
get messageHandlers() {
|
||||
return this.extensionMessageHandlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies if the passed element corresponds to the inline menu button or list.
|
||||
*
|
||||
* @param element - The element being checked
|
||||
*/
|
||||
isElementInlineMenu(element: HTMLElement) {
|
||||
return element === this.buttonElement || element === this.listElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the inline menu button is visible at the top frame.
|
||||
*/
|
||||
private async isInlineMenuButtonVisible() {
|
||||
return (
|
||||
!!this.buttonElement &&
|
||||
(await this.sendExtensionMessage("checkIsAutofillInlineMenuButtonVisible")) === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the inline menu list if visible at the top frame.
|
||||
*/
|
||||
private async isInlineMenuListVisible() {
|
||||
return (
|
||||
!!this.listElement &&
|
||||
(await this.sendExtensionMessage("checkIsAutofillInlineMenuListVisible")) === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the autofill inline menu from the page. This will initially
|
||||
* unobserve the body element to ensure the mutation observer no
|
||||
* longer triggers.
|
||||
*/
|
||||
private closeInlineMenu = (message?: AutofillExtensionMessage) => {
|
||||
if (message?.overlayElement === AutofillOverlayElement.Button) {
|
||||
this.closeInlineMenuButton();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.overlayElement === AutofillOverlayElement.List) {
|
||||
this.closeInlineMenuList();
|
||||
return;
|
||||
}
|
||||
|
||||
this.unobserveBodyElement();
|
||||
this.closeInlineMenuButton();
|
||||
this.closeInlineMenuList();
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes the inline menu button from the DOM if it is currently present. Will
|
||||
* also remove the inline menu reposition event listeners.
|
||||
*/
|
||||
private closeInlineMenuButton() {
|
||||
if (this.buttonElement) {
|
||||
this.buttonElement.remove();
|
||||
void this.sendExtensionMessage("autofillOverlayElementClosed", {
|
||||
overlayElement: AutofillOverlayElement.Button,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the inline menu list from the DOM if it is currently present.
|
||||
*/
|
||||
private closeInlineMenuList() {
|
||||
if (this.listElement) {
|
||||
this.listElement.remove();
|
||||
void this.sendExtensionMessage("autofillOverlayElementClosed", {
|
||||
overlayElement: AutofillOverlayElement.List,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the position of both the inline menu button and inline menu list.
|
||||
*/
|
||||
private async appendInlineMenuElements({ overlayElement }: AutofillExtensionMessage) {
|
||||
if (overlayElement === AutofillOverlayElement.Button) {
|
||||
return this.appendButtonElement();
|
||||
}
|
||||
|
||||
return this.appendListElement();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the position of the inline menu button.
|
||||
*/
|
||||
private async appendButtonElement(): Promise<void> {
|
||||
if (!this.buttonElement) {
|
||||
this.createButtonElement();
|
||||
this.updateCustomElementDefaultStyles(this.buttonElement);
|
||||
}
|
||||
|
||||
if (!(await this.isInlineMenuButtonVisible())) {
|
||||
this.appendInlineMenuElementToBody(this.buttonElement);
|
||||
this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.Button, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the position of the inline menu list.
|
||||
*/
|
||||
private async appendListElement(): Promise<void> {
|
||||
if (!this.listElement) {
|
||||
this.createListElement();
|
||||
this.updateCustomElementDefaultStyles(this.listElement);
|
||||
}
|
||||
|
||||
if (!(await this.isInlineMenuListVisible())) {
|
||||
this.appendInlineMenuElementToBody(this.listElement);
|
||||
this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.List, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the visibility status of the inline menu element within the background script.
|
||||
*
|
||||
* @param overlayElement - The inline menu element to update the visibility status for.
|
||||
* @param isVisible - The visibility status to update the inline menu element to.
|
||||
*/
|
||||
private updateInlineMenuElementIsVisibleStatus(
|
||||
overlayElement: AutofillOverlayElementType,
|
||||
isVisible: boolean,
|
||||
) {
|
||||
void this.sendExtensionMessage("updateAutofillInlineMenuElementIsVisibleStatus", {
|
||||
overlayElement,
|
||||
isVisible,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the inline menu element to the body element. This method will also
|
||||
* observe the body element to ensure that the inline menu element is not
|
||||
* interfered with by any DOM changes.
|
||||
*
|
||||
* @param element - The inline menu element to append to the body element.
|
||||
*/
|
||||
private appendInlineMenuElementToBody(element: HTMLElement) {
|
||||
this.observeBodyElement();
|
||||
globalThis.document.body.appendChild(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the autofill inline menu button element. Will not attempt
|
||||
* to create the element if it already exists in the DOM.
|
||||
*/
|
||||
private createButtonElement() {
|
||||
if (this.isFirefoxBrowser) {
|
||||
this.buttonElement = globalThis.document.createElement("div");
|
||||
new AutofillInlineMenuButtonIframe(this.buttonElement);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const customElementName = this.generateRandomCustomElementName();
|
||||
globalThis.customElements?.define(
|
||||
customElementName,
|
||||
class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
new AutofillInlineMenuButtonIframe(this);
|
||||
}
|
||||
},
|
||||
);
|
||||
this.buttonElement = globalThis.document.createElement(customElementName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the autofill inline menu list element. Will not attempt
|
||||
* to create the element if it already exists in the DOM.
|
||||
*/
|
||||
private createListElement() {
|
||||
if (this.isFirefoxBrowser) {
|
||||
this.listElement = globalThis.document.createElement("div");
|
||||
new AutofillInlineMenuListIframe(this.listElement);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const customElementName = this.generateRandomCustomElementName();
|
||||
globalThis.customElements?.define(
|
||||
customElementName,
|
||||
class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
new AutofillInlineMenuListIframe(this);
|
||||
}
|
||||
},
|
||||
);
|
||||
this.listElement = 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.
|
||||
*
|
||||
* @param element - The custom element to update the default styles for.
|
||||
*/
|
||||
private updateCustomElementDefaultStyles(element: HTMLElement) {
|
||||
this.unobserveCustomElements();
|
||||
|
||||
this.setElementStyles(element, this.customElementDefaultStyles, true);
|
||||
|
||||
this.observeCustomElements();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up mutation observers for the inline menu elements, the body element, and
|
||||
* the document element. The mutation observers are used to remove any styles that
|
||||
* are added to the inline menu elements by the website. They are also used to ensure
|
||||
* that the inline menu elements are always present at the bottom of the body element.
|
||||
*/
|
||||
private setupMutationObserver = () => {
|
||||
this.inlineMenuElementsMutationObserver = new MutationObserver(
|
||||
this.handleInlineMenuElementMutationObserverUpdate,
|
||||
);
|
||||
|
||||
this.bodyElementMutationObserver = new MutationObserver(
|
||||
this.handleBodyElementMutationObserverUpdate,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up mutation observers to verify that the inline menu
|
||||
* elements are not modified by the website.
|
||||
*/
|
||||
private observeCustomElements() {
|
||||
if (this.buttonElement) {
|
||||
this.inlineMenuElementsMutationObserver?.observe(this.buttonElement, {
|
||||
attributes: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.listElement) {
|
||||
this.inlineMenuElementsMutationObserver?.observe(this.listElement, { attributes: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects the mutation observers that are used to verify that the inline menu
|
||||
* elements are not modified by the website.
|
||||
*/
|
||||
private unobserveCustomElements() {
|
||||
this.inlineMenuElementsMutationObserver?.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a mutation observer for the body element. The mutation observer is used
|
||||
* to ensure that the inline menu 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 unobserveBodyElement() {
|
||||
this.bodyElementMutationObserver?.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the mutation observer update for the inline menu elements. This method will
|
||||
* remove any attributes or styles that might be added to the inline menu elements by
|
||||
* a separate process within the website where this script is injected.
|
||||
*
|
||||
* @param mutationRecord - The mutation record that triggered the update.
|
||||
*/
|
||||
private handleInlineMenuElementMutationObserverUpdate = (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);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes all elements from a passed inline menu
|
||||
* element except for the style attribute.
|
||||
*
|
||||
* @param element - The element to remove the attributes from.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
element.removeAttribute(attribute.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the mutation observer update for the body element. This method will
|
||||
* ensure that the inline menu elements are always present at the bottom of the
|
||||
* body element.
|
||||
*/
|
||||
private handleBodyElementMutationObserverUpdate = async () => {
|
||||
if (
|
||||
(!this.buttonElement && !this.listElement) ||
|
||||
this.isTriggeringExcessiveMutationObserverIterations()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastChild = globalThis.document.body.lastElementChild;
|
||||
const secondToLastChild = lastChild?.previousElementSibling;
|
||||
const lastChildIsInlineMenuList = lastChild === this.listElement;
|
||||
const lastChildIsInlineMenuButton = lastChild === this.buttonElement;
|
||||
const secondToLastChildIsInlineMenuButton = secondToLastChild === this.buttonElement;
|
||||
|
||||
if (
|
||||
!lastChild ||
|
||||
(lastChildIsInlineMenuList && secondToLastChildIsInlineMenuButton) ||
|
||||
(lastChildIsInlineMenuButton && !(await this.isInlineMenuListVisible()))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(lastChildIsInlineMenuList && !secondToLastChildIsInlineMenuButton) ||
|
||||
(lastChildIsInlineMenuButton && (await this.isInlineMenuListVisible()))
|
||||
) {
|
||||
globalThis.document.body.insertBefore(this.buttonElement, this.listElement);
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.document.body.insertBefore(lastChild, this.buttonElement);
|
||||
};
|
||||
|
||||
/**
|
||||
* Identifies if the mutation observer is triggering excessive iterations.
|
||||
* Will trigger a blur of the most recently focused field and remove the
|
||||
* autofill inline menu if any set mutation observer is triggering
|
||||
* excessive iterations.
|
||||
*/
|
||||
private isTriggeringExcessiveMutationObserverIterations() {
|
||||
if (this.mutationObserverIterationsResetTimeout) {
|
||||
clearTimeout(this.mutationObserverIterationsResetTimeout);
|
||||
}
|
||||
|
||||
this.mutationObserverIterations++;
|
||||
this.mutationObserverIterationsResetTimeout = setTimeout(
|
||||
() => (this.mutationObserverIterations = 0),
|
||||
2000,
|
||||
);
|
||||
|
||||
if (this.mutationObserverIterations > 100) {
|
||||
clearTimeout(this.mutationObserverIterationsResetTimeout);
|
||||
this.mutationObserverIterations = 0;
|
||||
this.closeInlineMenu();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects the mutation observers and removes the inline menu elements from the DOM.
|
||||
*/
|
||||
destroy() {
|
||||
this.closeInlineMenu();
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AutofillInlineMenuIframeService initMenuIframe sets up the iframe's attributes 1`] = `
|
||||
<iframe
|
||||
allowtransparency="true"
|
||||
src="chrome-extension://id/overlay/menu.html"
|
||||
style="all: initial !important; position: fixed !important; display: block !important; z-index: 2147483647 !important; line-height: 0 !important; overflow: hidden !important; transition: opacity 125ms ease-out 0s !important; visibility: visible !important; clip-path: none !important; pointer-events: auto !important; margin: 0px !important; padding: 0px !important; color-scheme: normal !important; opacity: 0 !important; height: 0px;"
|
||||
tabindex="-1"
|
||||
title="title"
|
||||
/>
|
||||
`;
|
@ -0,0 +1,27 @@
|
||||
import { AutofillInlineMenuButtonIframe } from "./autofill-inline-menu-button-iframe";
|
||||
|
||||
describe("AutofillInlineMenuButtonIframe", () => {
|
||||
window.customElements.define(
|
||||
"autofill-inline-menu-button-iframe",
|
||||
class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
new AutofillInlineMenuButtonIframe(this);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a custom element that is an instance of the AutofillIframeElement parent class", () => {
|
||||
document.body.innerHTML =
|
||||
"<autofill-inline-menu-button-iframe></autofill-inline-menu-button-iframe>";
|
||||
|
||||
const iframe = document.querySelector("autofill-inline-menu-button-iframe");
|
||||
|
||||
expect(iframe).toBeInstanceOf(HTMLElement);
|
||||
expect(iframe.shadowRoot).toBeDefined();
|
||||
});
|
||||
});
|
@ -0,0 +1,18 @@
|
||||
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
|
||||
|
||||
import { AutofillInlineMenuIframeElement } from "./autofill-inline-menu-iframe-element";
|
||||
|
||||
export class AutofillInlineMenuButtonIframe extends AutofillInlineMenuIframeElement {
|
||||
constructor(element: HTMLElement) {
|
||||
super(
|
||||
element,
|
||||
AutofillOverlayPort.Button,
|
||||
{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
},
|
||||
chrome.i18n.getMessage("bitwardenOverlayButton"),
|
||||
chrome.i18n.getMessage("bitwardenOverlayMenuAvailable"),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
|
||||
|
||||
import { AutofillInlineMenuIframeElement } from "./autofill-inline-menu-iframe-element";
|
||||
import { AutofillInlineMenuIframeService } from "./autofill-inline-menu-iframe.service";
|
||||
|
||||
jest.mock("./autofill-inline-menu-iframe.service");
|
||||
|
||||
describe("AutofillInlineMenuIframeElement", () => {
|
||||
window.customElements.define(
|
||||
"autofill-inline-menu-iframe",
|
||||
class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
new AutofillInlineMenuIframeElement(
|
||||
this,
|
||||
AutofillOverlayPort.Button,
|
||||
{ background: "transparent", border: "none" },
|
||||
"bitwardenInlineMenuButton",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a custom element that is an instance of the HTMLElement parent class", () => {
|
||||
document.body.innerHTML = "<autofill-inline-menu-iframe></autofill-inline-menu-iframe>";
|
||||
|
||||
const iframe = document.querySelector("autofill-inline-menu-iframe");
|
||||
|
||||
expect(iframe).toBeInstanceOf(HTMLElement);
|
||||
});
|
||||
|
||||
it("attaches a closed shadow DOM", () => {
|
||||
document.body.innerHTML = "<autofill-inline-menu-iframe></autofill-inline-menu-iframe>";
|
||||
|
||||
const iframe = document.querySelector("autofill-inline-menu-iframe");
|
||||
|
||||
expect(iframe.shadowRoot).toBeNull();
|
||||
});
|
||||
|
||||
it("instantiates the autofill inline menu iframe service for each attached custom element", () => {
|
||||
expect(AutofillInlineMenuIframeService).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
@ -0,0 +1,21 @@
|
||||
import { AutofillInlineMenuIframeService } from "./autofill-inline-menu-iframe.service";
|
||||
|
||||
export class AutofillInlineMenuIframeElement {
|
||||
constructor(
|
||||
element: HTMLElement,
|
||||
portName: string,
|
||||
initStyles: Partial<CSSStyleDeclaration>,
|
||||
iframeTitle: string,
|
||||
ariaAlert?: string,
|
||||
) {
|
||||
const shadow: ShadowRoot = element.attachShadow({ mode: "closed" });
|
||||
const autofillInlineMenuIframeService = new AutofillInlineMenuIframeService(
|
||||
shadow,
|
||||
portName,
|
||||
initStyles,
|
||||
iframeTitle,
|
||||
ariaAlert,
|
||||
);
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
}
|
||||
}
|
@ -0,0 +1,521 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
|
||||
import { createPortSpyMock } from "../../../spec/autofill-mocks";
|
||||
import {
|
||||
flushPromises,
|
||||
sendPortMessage,
|
||||
triggerPortOnDisconnectEvent,
|
||||
} from "../../../spec/testing-utils";
|
||||
|
||||
import { AutofillInlineMenuIframeService } from "./autofill-inline-menu-iframe.service";
|
||||
|
||||
describe("AutofillInlineMenuIframeService", () => {
|
||||
let autofillInlineMenuIframeService: AutofillInlineMenuIframeService;
|
||||
let portSpy: chrome.runtime.Port;
|
||||
let shadowAppendSpy: jest.SpyInstance;
|
||||
let handlePortDisconnectSpy: jest.SpyInstance;
|
||||
let handlePortMessageSpy: jest.SpyInstance;
|
||||
let sendExtensionMessageSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
const shadow = document.createElement("div").attachShadow({ mode: "open" });
|
||||
autofillInlineMenuIframeService = new AutofillInlineMenuIframeService(
|
||||
shadow,
|
||||
AutofillOverlayPort.Button,
|
||||
{ height: "0px" },
|
||||
"title",
|
||||
"ariaAlert",
|
||||
);
|
||||
shadowAppendSpy = jest.spyOn(shadow, "appendChild");
|
||||
handlePortDisconnectSpy = jest.spyOn(
|
||||
autofillInlineMenuIframeService as any,
|
||||
"handlePortDisconnect",
|
||||
);
|
||||
handlePortMessageSpy = jest.spyOn(autofillInlineMenuIframeService as any, "handlePortMessage");
|
||||
chrome.runtime.connect = jest.fn((connectInfo: chrome.runtime.ConnectInfo) =>
|
||||
createPortSpyMock(connectInfo.name),
|
||||
) as unknown as typeof chrome.runtime.connect;
|
||||
sendExtensionMessageSpy = jest.spyOn(
|
||||
autofillInlineMenuIframeService as any,
|
||||
"sendExtensionMessage",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("initMenuIframe", () => {
|
||||
it("sets up the iframe's attributes", () => {
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
|
||||
expect(autofillInlineMenuIframeService["iframe"]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("appends the iframe to the shadowDom", () => {
|
||||
jest.spyOn(autofillInlineMenuIframeService["shadow"], "appendChild");
|
||||
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
|
||||
expect(autofillInlineMenuIframeService["shadow"].appendChild).toHaveBeenCalledWith(
|
||||
autofillInlineMenuIframeService["iframe"],
|
||||
);
|
||||
});
|
||||
|
||||
// TODO CG - This test is brittle and failing due to how we are calling the private method. This needs to be reworked
|
||||
it.skip("creates an aria alert element if the ariaAlert param is passed", () => {
|
||||
const ariaAlert = "aria alert";
|
||||
jest.spyOn(autofillInlineMenuIframeService as any, "createAriaAlertElement");
|
||||
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
|
||||
expect(autofillInlineMenuIframeService["createAriaAlertElement"]).toHaveBeenCalledWith(
|
||||
ariaAlert,
|
||||
);
|
||||
expect(autofillInlineMenuIframeService["ariaAlertElement"]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("on load of the iframe source", () => {
|
||||
beforeEach(() => {
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
});
|
||||
|
||||
it("sets up and connects the port message listener to the extension background", () => {
|
||||
jest.spyOn(globalThis, "addEventListener");
|
||||
|
||||
autofillInlineMenuIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
|
||||
portSpy = autofillInlineMenuIframeService["port"];
|
||||
|
||||
expect(chrome.runtime.connect).toHaveBeenCalledWith({ name: AutofillOverlayPort.Button });
|
||||
expect(portSpy.onDisconnect.addListener).toHaveBeenCalledWith(handlePortDisconnectSpy);
|
||||
expect(portSpy.onMessage.addListener).toHaveBeenCalledWith(handlePortMessageSpy);
|
||||
});
|
||||
|
||||
it("skips announcing the aria alert if the aria alert element is not populated", () => {
|
||||
jest.spyOn(globalThis, "setTimeout");
|
||||
autofillInlineMenuIframeService["ariaAlertElement"] = undefined;
|
||||
|
||||
autofillInlineMenuIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
|
||||
|
||||
expect(globalThis.setTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces the aria alert if the aria alert element is populated", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(globalThis, "setTimeout");
|
||||
autofillInlineMenuIframeService["ariaAlertElement"] = document.createElement("div");
|
||||
autofillInlineMenuIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 2000);
|
||||
|
||||
autofillInlineMenuIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
|
||||
|
||||
expect(globalThis.setTimeout).toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(2000);
|
||||
|
||||
expect(shadowAppendSpy).toHaveBeenCalledWith(
|
||||
autofillInlineMenuIframeService["ariaAlertElement"],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("event listeners", () => {
|
||||
beforeEach(() => {
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
autofillInlineMenuIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
|
||||
Object.defineProperty(autofillInlineMenuIframeService["iframe"], "contentWindow", {
|
||||
value: {
|
||||
postMessage: jest.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
jest.spyOn(autofillInlineMenuIframeService["iframe"].contentWindow, "postMessage");
|
||||
portSpy = autofillInlineMenuIframeService["port"];
|
||||
});
|
||||
|
||||
describe("handlePortDisconnect", () => {
|
||||
it("ignores ports that do not have the correct port name", () => {
|
||||
portSpy.name = "wrong-port-name";
|
||||
triggerPortOnDisconnectEvent(portSpy);
|
||||
|
||||
expect(autofillInlineMenuIframeService["port"]).not.toBeNull();
|
||||
});
|
||||
|
||||
it("resets the iframe element's opacity, height, and display styles", () => {
|
||||
triggerPortOnDisconnectEvent(portSpy);
|
||||
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.opacity).toBe("0");
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.height).toBe("0px");
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.display).toBe("block");
|
||||
});
|
||||
|
||||
it("removes the port's onMessage listener", () => {
|
||||
triggerPortOnDisconnectEvent(portSpy);
|
||||
|
||||
expect(portSpy.onMessage.removeListener).toHaveBeenCalledWith(handlePortMessageSpy);
|
||||
});
|
||||
|
||||
it("removes the port's onDisconnect listener", () => {
|
||||
triggerPortOnDisconnectEvent(portSpy);
|
||||
|
||||
expect(portSpy.onDisconnect.removeListener).toHaveBeenCalledWith(handlePortDisconnectSpy);
|
||||
});
|
||||
|
||||
it("disconnects the port", () => {
|
||||
triggerPortOnDisconnectEvent(portSpy);
|
||||
|
||||
expect(portSpy.disconnect).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuIframeService["port"]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlePortMessage", () => {
|
||||
it("ignores port messages that do not correlate to the correct port name", () => {
|
||||
portSpy.name = "wrong-port-name";
|
||||
sendPortMessage(portSpy, {});
|
||||
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes on the message to the iframe if the message is not registered with the message handlers", () => {
|
||||
const message = { command: "unregisteredMessage" };
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
).toHaveBeenCalledWith(message, "*");
|
||||
});
|
||||
|
||||
it("handles port messages that are registered with the message handlers and does not pass the message on to the iframe", () => {
|
||||
jest.spyOn(autofillInlineMenuIframeService as any, "updateIframePosition");
|
||||
|
||||
sendPortMessage(portSpy, { command: "updateAutofillInlineMenuPosition" });
|
||||
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("initializing the inline menu button", () => {
|
||||
it("sets the port key and posts the message to the inline menu page iframe", () => {
|
||||
const portKey = "portKey";
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuButton",
|
||||
portKey,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(autofillInlineMenuIframeService["portKey"]).toBe(portKey);
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
).toHaveBeenCalledWith(message, "*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initializing the inline menu list", () => {
|
||||
let updateElementStylesSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
updateElementStylesSpy = jest.spyOn(
|
||||
autofillInlineMenuIframeService as any,
|
||||
"updateElementStyles",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the message on to the iframe element", () => {
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Light,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(updateElementStylesSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
).toHaveBeenCalledWith(message, "*");
|
||||
});
|
||||
|
||||
it("sets a light theme based on the user's system preferences", () => {
|
||||
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: false }));
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.System,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Light,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets a dark theme based on the user's system preferences", () => {
|
||||
window.matchMedia = jest.fn(() => mock<MediaQueryList>({ matches: true }));
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.System,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Dark,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the border to match the `dark` theme", () => {
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Dark,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(updateElementStylesSpy).toHaveBeenCalledWith(
|
||||
autofillInlineMenuIframeService["iframe"],
|
||||
{
|
||||
borderColor: "#4c525f",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the border to match the `nord` theme", () => {
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.Nord,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(updateElementStylesSpy).toHaveBeenCalledWith(
|
||||
autofillInlineMenuIframeService["iframe"],
|
||||
{
|
||||
borderColor: "#2E3440",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the border to match the `solarizedDark` theme", () => {
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
theme: ThemeType.SolarizedDark,
|
||||
};
|
||||
|
||||
sendPortMessage(portSpy, message);
|
||||
|
||||
expect(updateElementStylesSpy).toHaveBeenCalledWith(
|
||||
autofillInlineMenuIframeService["iframe"],
|
||||
{
|
||||
borderColor: "#073642",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updating the iframe's position", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("ignores updating the iframe position if the document does not have focus", () => {
|
||||
jest.spyOn(autofillInlineMenuIframeService as any, "updateElementStyles");
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles: { top: 100, left: 100 },
|
||||
});
|
||||
|
||||
expect(autofillInlineMenuIframeService["updateElementStyles"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates the iframe position if the document has focus", () => {
|
||||
const styles = { top: "100px", left: "100px" };
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles,
|
||||
});
|
||||
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.top).toBe(styles.top);
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.left).toBe(styles.left);
|
||||
});
|
||||
|
||||
it("announces the opening of the iframe using an aria alert", () => {
|
||||
jest.useFakeTimers();
|
||||
const styles = { top: "100px", left: "100px" };
|
||||
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuPosition",
|
||||
styles,
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
expect(shadowAppendSpy).toHaveBeenCalledWith(
|
||||
autofillInlineMenuIframeService["ariaAlertElement"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the visibility of the iframe", () => {
|
||||
sendPortMessage(portSpy, {
|
||||
command: "toggleAutofillInlineMenuHidden",
|
||||
styles: { display: "none" },
|
||||
});
|
||||
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("updates the button based on the web page's color scheme", () => {
|
||||
sendPortMessage(portSpy, {
|
||||
command: "updateAutofillInlineMenuColorScheme",
|
||||
});
|
||||
|
||||
expect(
|
||||
autofillInlineMenuIframeService["iframe"].contentWindow.postMessage,
|
||||
).toHaveBeenCalledWith(
|
||||
{
|
||||
command: "updateAutofillInlineMenuColorScheme",
|
||||
colorScheme: "normal",
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("triggers a delayed closure of the inline menu", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuIframeService["delayedCloseTimeout"] = setTimeout(jest.fn, 100);
|
||||
|
||||
sendPortMessage(portSpy, { command: "triggerDelayedAutofillInlineMenuClosure" });
|
||||
expect(clearTimeout).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.opacity).toBe("0");
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.transition).toBe(
|
||||
"opacity 65ms ease-out 0s",
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(100);
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.transition).toBe(
|
||||
"opacity 125ms ease-out 0s",
|
||||
);
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("triggers a fade in of the inline menu", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuIframeService["fadeInTimeout"] = setTimeout(jest.fn, 10);
|
||||
|
||||
sendPortMessage(portSpy, { command: "fadeInAutofillInlineMenuIframe" });
|
||||
expect(clearTimeout).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.opacity).toBe("0");
|
||||
|
||||
jest.advanceTimersByTime(10);
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.opacity).toBe("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mutation observer", () => {
|
||||
beforeEach(() => {
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
autofillInlineMenuIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
|
||||
portSpy = autofillInlineMenuIframeService["port"];
|
||||
});
|
||||
|
||||
it("skips handling found mutations if excessive mutations are triggering", async () => {
|
||||
jest.useFakeTimers();
|
||||
jest
|
||||
.spyOn(
|
||||
autofillInlineMenuIframeService as any,
|
||||
"isTriggeringExcessiveMutationObserverIterations",
|
||||
)
|
||||
.mockReturnValue(true);
|
||||
jest.spyOn(autofillInlineMenuIframeService as any, "updateElementStyles");
|
||||
|
||||
autofillInlineMenuIframeService["iframe"].style.visibility = "hidden";
|
||||
await flushPromises();
|
||||
|
||||
expect(autofillInlineMenuIframeService["updateElementStyles"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reverts any styles changes made directly to the iframe", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
autofillInlineMenuIframeService["iframe"].style.visibility = "hidden";
|
||||
await flushPromises();
|
||||
|
||||
expect(autofillInlineMenuIframeService["iframe"].style.visibility).toBe("visible");
|
||||
});
|
||||
|
||||
it("force closes the autofill inline menu if more than 9 foreign mutations are triggered", async () => {
|
||||
jest.useFakeTimers();
|
||||
autofillInlineMenuIframeService["foreignMutationsCount"] = 10;
|
||||
|
||||
autofillInlineMenuIframeService["iframe"].src = "http://malicious-site.com";
|
||||
await flushPromises();
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("force closes the autofill overinline menulay if excessive mutations are being triggered", async () => {
|
||||
jest.useFakeTimers();
|
||||
autofillInlineMenuIframeService["mutationObserverIterations"] = 20;
|
||||
|
||||
autofillInlineMenuIframeService["iframe"].src = "http://malicious-site.com";
|
||||
await flushPromises();
|
||||
|
||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
|
||||
forceCloseInlineMenu: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("resets the excessive mutations and foreign mutation counters", async () => {
|
||||
jest.useFakeTimers();
|
||||
autofillInlineMenuIframeService["foreignMutationsCount"] = 9;
|
||||
autofillInlineMenuIframeService["mutationObserverIterations"] = 19;
|
||||
|
||||
autofillInlineMenuIframeService["iframe"].src = "http://malicious-site.com";
|
||||
jest.advanceTimersByTime(2001);
|
||||
await flushPromises();
|
||||
|
||||
expect(autofillInlineMenuIframeService["foreignMutationsCount"]).toBe(0);
|
||||
expect(autofillInlineMenuIframeService["mutationObserverIterations"]).toBe(0);
|
||||
});
|
||||
|
||||
it("resets any mutated default attributes for the iframe", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
autofillInlineMenuIframeService["iframe"].title = "some-other-title";
|
||||
await flushPromises();
|
||||
|
||||
expect(autofillInlineMenuIframeService["iframe"].title).toBe("title");
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,457 @@
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { sendExtensionMessage, setElementStyles } from "../../../utils";
|
||||
import {
|
||||
BackgroundPortMessageHandlers,
|
||||
AutofillInlineMenuIframeService as AutofillInlineMenuIframeServiceInterface,
|
||||
AutofillInlineMenuIframeExtensionMessage,
|
||||
} from "../abstractions/autofill-inline-menu-iframe.service";
|
||||
|
||||
export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframeServiceInterface {
|
||||
private readonly setElementStyles = setElementStyles;
|
||||
private readonly sendExtensionMessage = sendExtensionMessage;
|
||||
private port: chrome.runtime.Port | null = null;
|
||||
private portKey: string;
|
||||
private iframeMutationObserver: MutationObserver;
|
||||
private iframe: HTMLIFrameElement;
|
||||
private ariaAlertElement: HTMLDivElement;
|
||||
private ariaAlertTimeout: number | NodeJS.Timeout;
|
||||
private delayedCloseTimeout: number | NodeJS.Timeout;
|
||||
private fadeInTimeout: number | NodeJS.Timeout;
|
||||
private readonly fadeInOpacityTransition = "opacity 125ms ease-out 0s";
|
||||
private readonly fadeOutOpacityTransition = "opacity 65ms ease-out 0s";
|
||||
private iframeStyles: Partial<CSSStyleDeclaration> = {
|
||||
all: "initial",
|
||||
position: "fixed",
|
||||
display: "block",
|
||||
zIndex: "2147483647",
|
||||
lineHeight: "0",
|
||||
overflow: "hidden",
|
||||
transition: this.fadeInOpacityTransition,
|
||||
visibility: "visible",
|
||||
clipPath: "none",
|
||||
pointerEvents: "auto",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
colorScheme: "normal",
|
||||
opacity: "0",
|
||||
};
|
||||
private defaultIframeAttributes: Record<string, string> = {
|
||||
src: "",
|
||||
title: "",
|
||||
allowtransparency: "true",
|
||||
tabIndex: "-1",
|
||||
};
|
||||
private foreignMutationsCount = 0;
|
||||
private mutationObserverIterations = 0;
|
||||
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout;
|
||||
private readonly backgroundPortMessageHandlers: BackgroundPortMessageHandlers = {
|
||||
initAutofillInlineMenuButton: ({ message }) => this.initAutofillInlineMenu(message),
|
||||
initAutofillInlineMenuList: ({ message }) => this.initAutofillInlineMenu(message),
|
||||
updateAutofillInlineMenuPosition: ({ message }) => this.updateIframePosition(message.styles),
|
||||
toggleAutofillInlineMenuHidden: ({ message }) =>
|
||||
this.updateElementStyles(this.iframe, message.styles),
|
||||
updateAutofillInlineMenuColorScheme: () => this.updateAutofillInlineMenuColorScheme(),
|
||||
triggerDelayedAutofillInlineMenuClosure: () => this.handleDelayedAutofillInlineMenuClosure(),
|
||||
fadeInAutofillInlineMenuIframe: () => this.handleFadeInInlineMenuIframe(),
|
||||
};
|
||||
|
||||
constructor(
|
||||
private shadow: ShadowRoot,
|
||||
private portName: string,
|
||||
private initStyles: Partial<CSSStyleDeclaration>,
|
||||
private iframeTitle: string,
|
||||
private ariaAlert?: string,
|
||||
) {
|
||||
this.iframeMutationObserver = new MutationObserver(this.handleMutations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles initialization of the iframe which includes applying initial styles
|
||||
* to the iframe, setting the source, and adding listener that connects the
|
||||
* iframe to the background script each time it loads. Can conditionally
|
||||
* create an aria alert element to announce to screen readers when the iframe
|
||||
* is loaded. The end result is append to the shadowDOM of the custom element
|
||||
* that is declared.
|
||||
*/
|
||||
initMenuIframe() {
|
||||
this.defaultIframeAttributes.src = chrome.runtime.getURL("overlay/menu.html");
|
||||
this.defaultIframeAttributes.title = this.iframeTitle;
|
||||
|
||||
this.iframe = globalThis.document.createElement("iframe");
|
||||
this.updateElementStyles(this.iframe, { ...this.iframeStyles, ...this.initStyles });
|
||||
for (const [attribute, value] of Object.entries(this.defaultIframeAttributes)) {
|
||||
this.iframe.setAttribute(attribute, value);
|
||||
}
|
||||
this.iframe.addEventListener(EVENTS.LOAD, this.setupPortMessageListener);
|
||||
|
||||
if (this.ariaAlert) {
|
||||
this.createAriaAlertElement(this.ariaAlert);
|
||||
}
|
||||
|
||||
this.shadow.appendChild(this.iframe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an aria alert element that is used to announce to screen readers
|
||||
* when the iframe is loaded.
|
||||
*
|
||||
* @param ariaAlertText - Text to announce to screen readers when the iframe is loaded
|
||||
*/
|
||||
private createAriaAlertElement(ariaAlertText: string) {
|
||||
this.ariaAlertElement = globalThis.document.createElement("div");
|
||||
this.ariaAlertElement.setAttribute("role", "alert");
|
||||
this.ariaAlertElement.setAttribute("aria-live", "polite");
|
||||
this.ariaAlertElement.setAttribute("aria-atomic", "true");
|
||||
this.updateElementStyles(this.ariaAlertElement, {
|
||||
position: "absolute",
|
||||
top: "-9999px",
|
||||
left: "-9999px",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
overflow: "hidden",
|
||||
opacity: "0",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
this.ariaAlertElement.textContent = ariaAlertText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the port message listener to the extension background script. This
|
||||
* listener is used to communicate between the iframe and the background script.
|
||||
* This also facilitates announcing to screen readers when the iframe is loaded.
|
||||
*/
|
||||
private setupPortMessageListener = () => {
|
||||
this.port = chrome.runtime.connect({ name: this.portName });
|
||||
this.port.onDisconnect.addListener(this.handlePortDisconnect);
|
||||
this.port.onMessage.addListener(this.handlePortMessage);
|
||||
|
||||
this.announceAriaAlert();
|
||||
};
|
||||
|
||||
/**
|
||||
* Announces the aria alert element to screen readers when the iframe is loaded.
|
||||
*/
|
||||
private announceAriaAlert() {
|
||||
if (!this.ariaAlertElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ariaAlertElement.remove();
|
||||
if (this.ariaAlertTimeout) {
|
||||
clearTimeout(this.ariaAlertTimeout);
|
||||
}
|
||||
|
||||
this.ariaAlertTimeout = setTimeout(() => this.shadow.appendChild(this.ariaAlertElement), 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles disconnecting the port message listener from the extension background
|
||||
* script. This also removes the listener that facilitates announcing to screen
|
||||
* readers when the iframe is loaded.
|
||||
*
|
||||
* @param port - The port that is disconnected
|
||||
*/
|
||||
private handlePortDisconnect = (port: chrome.runtime.Port) => {
|
||||
if (port.name !== this.portName) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateElementStyles(this.iframe, { opacity: "0", height: "0px" });
|
||||
this.unobserveIframe();
|
||||
this.port?.onMessage.removeListener(this.handlePortMessage);
|
||||
this.port?.onDisconnect.removeListener(this.handlePortDisconnect);
|
||||
this.port?.disconnect();
|
||||
this.port = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles messages sent from the extension background script to the iframe.
|
||||
* Triggers behavior within the iframe as well as on the custom element that
|
||||
* contains the iframe element.
|
||||
*
|
||||
* @param message
|
||||
* @param port
|
||||
*/
|
||||
private handlePortMessage = (
|
||||
message: AutofillInlineMenuIframeExtensionMessage,
|
||||
port: chrome.runtime.Port,
|
||||
) => {
|
||||
if (port.name !== this.portName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.backgroundPortMessageHandlers[message.command]) {
|
||||
this.backgroundPortMessageHandlers[message.command]({ message, port });
|
||||
return;
|
||||
}
|
||||
|
||||
this.postMessageToIFrame(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the initialization of the autofill inline menu. This includes setting
|
||||
* the port key and sending a message to the iframe to initialize the inline menu.
|
||||
*
|
||||
* @param message
|
||||
* @private
|
||||
*/
|
||||
private initAutofillInlineMenu(message: AutofillInlineMenuIframeExtensionMessage) {
|
||||
this.portKey = message.portKey;
|
||||
if (message.command === "initAutofillInlineMenuList") {
|
||||
this.initAutofillInlineMenuList(message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.postMessageToIFrame(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles initialization of the autofill inline menu list. This includes setting
|
||||
* the theme and sending a message to the iframe to initialize the inline menu.
|
||||
*
|
||||
* @param message - The message sent from the iframe
|
||||
*/
|
||||
private initAutofillInlineMenuList(message: AutofillInlineMenuIframeExtensionMessage) {
|
||||
const { theme } = message;
|
||||
let borderColor: string;
|
||||
let verifiedTheme = theme;
|
||||
if (verifiedTheme === ThemeType.System) {
|
||||
verifiedTheme = globalThis.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? ThemeType.Dark
|
||||
: ThemeType.Light;
|
||||
}
|
||||
|
||||
if (verifiedTheme === ThemeType.Dark) {
|
||||
borderColor = "#4c525f";
|
||||
}
|
||||
if (theme === ThemeType.Nord) {
|
||||
borderColor = "#2E3440";
|
||||
}
|
||||
if (theme === ThemeType.SolarizedDark) {
|
||||
borderColor = "#073642";
|
||||
}
|
||||
if (borderColor) {
|
||||
this.updateElementStyles(this.iframe, { borderColor });
|
||||
}
|
||||
|
||||
message.theme = verifiedTheme;
|
||||
this.postMessageToIFrame(message);
|
||||
}
|
||||
|
||||
private postMessageToIFrame(message: any) {
|
||||
this.iframe.contentWindow?.postMessage({ portKey: this.portKey, ...message }, "*");
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the position of the iframe element. Will also announce
|
||||
* to screen readers that the iframe is open.
|
||||
*
|
||||
* @param position - The position styles to apply to the iframe
|
||||
*/
|
||||
private updateIframePosition(position: Partial<CSSStyleDeclaration>) {
|
||||
if (!globalThis.document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearFadeInTimeout();
|
||||
this.updateElementStyles(this.iframe, position);
|
||||
this.announceAriaAlert();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the page color scheme meta tag and sends a message to the iframe
|
||||
* to update its color scheme. Will default to "normal" if the meta tag
|
||||
* does not exist.
|
||||
*/
|
||||
private updateAutofillInlineMenuColorScheme() {
|
||||
const colorSchemeValue = globalThis.document
|
||||
.querySelector("meta[name='color-scheme']")
|
||||
?.getAttribute("content");
|
||||
|
||||
this.postMessageToIFrame({
|
||||
command: "updateAutofillInlineMenuColorScheme",
|
||||
colorScheme: colorSchemeValue || "normal",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts an element and updates the styles for that element. This method
|
||||
* will also unobserve the element if it is the iframe element. This is
|
||||
* done to ensure that we do not trigger the mutation observer when we
|
||||
* update the styles for the iframe.
|
||||
*
|
||||
* @param customElement - The element to update the styles for
|
||||
* @param styles - The styles to apply to the element
|
||||
*/
|
||||
private updateElementStyles(customElement: HTMLElement, styles: Partial<CSSStyleDeclaration>) {
|
||||
if (!customElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unobserveIframe();
|
||||
|
||||
this.setElementStyles(customElement, styles, true);
|
||||
if (customElement === this.iframe) {
|
||||
this.iframeStyles = { ...this.iframeStyles, ...styles };
|
||||
}
|
||||
|
||||
this.observeIframe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a forced closure of the autofill inline menu. This is used when the
|
||||
* mutation observer is triggered excessively.
|
||||
*/
|
||||
private forceCloseInlineMenu() {
|
||||
void this.sendExtensionMessage("closeAutofillInlineMenu", { forceCloseInlineMenu: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a fade in effect for the inline menu iframe. Initialized by the background context.
|
||||
*/
|
||||
private handleFadeInInlineMenuIframe() {
|
||||
this.clearFadeInTimeout();
|
||||
this.fadeInTimeout = globalThis.setTimeout(
|
||||
() => this.updateElementStyles(this.iframe, { display: "block", opacity: "1" }),
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the fade in timeout for the inline menu iframe.
|
||||
*/
|
||||
private clearFadeInTimeout() {
|
||||
if (this.fadeInTimeout) {
|
||||
clearTimeout(this.fadeInTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a delayed closure of the inline menu to ensure that click events are
|
||||
* caught if focus is programmatically redirected away from the inline menu.
|
||||
*/
|
||||
private handleDelayedAutofillInlineMenuClosure() {
|
||||
if (this.delayedCloseTimeout) {
|
||||
clearTimeout(this.delayedCloseTimeout);
|
||||
}
|
||||
|
||||
this.updateElementStyles(this.iframe, {
|
||||
transition: this.fadeOutOpacityTransition,
|
||||
opacity: "0",
|
||||
});
|
||||
|
||||
this.delayedCloseTimeout = globalThis.setTimeout(() => {
|
||||
this.updateElementStyles(this.iframe, { transition: this.fadeInOpacityTransition });
|
||||
this.forceCloseInlineMenu();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mutations to the iframe element. The ensures that the iframe
|
||||
* element's styles are not modified by a third party source.
|
||||
*
|
||||
* @param mutations - The mutations to the iframe element
|
||||
*/
|
||||
private handleMutations = (mutations: MutationRecord[]) => {
|
||||
if (this.isTriggeringExcessiveMutationObserverIterations()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 0; index < mutations.length; index++) {
|
||||
const mutation = mutations[index];
|
||||
if (mutation.type !== "attributes") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const element = mutation.target as HTMLElement;
|
||||
if (mutation.attributeName !== "style") {
|
||||
this.handleElementAttributeMutation(element);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
this.iframe.removeAttribute("style");
|
||||
this.updateElementStyles(this.iframe, this.iframeStyles);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles mutations to the iframe element's attributes. This ensures that
|
||||
* the iframe element's attributes are not modified by a third party source.
|
||||
*
|
||||
* @param element - The element to handle attribute mutations for
|
||||
*/
|
||||
private handleElementAttributeMutation(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;
|
||||
}
|
||||
|
||||
if (this.foreignMutationsCount >= 10) {
|
||||
this.forceCloseInlineMenu();
|
||||
break;
|
||||
}
|
||||
|
||||
const defaultIframeAttribute = this.defaultIframeAttributes[attribute.name];
|
||||
if (!defaultIframeAttribute) {
|
||||
this.iframe.removeAttribute(attribute.name);
|
||||
this.foreignMutationsCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attribute.value === defaultIframeAttribute) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.iframe.setAttribute(attribute.name, defaultIframeAttribute);
|
||||
this.foreignMutationsCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the iframe element for mutations to its style attribute.
|
||||
*/
|
||||
private observeIframe() {
|
||||
this.iframeMutationObserver.observe(this.iframe, { attributes: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unobserves the iframe element for mutations to its style attribute.
|
||||
*/
|
||||
private unobserveIframe() {
|
||||
this.iframeMutationObserver?.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies if the mutation observer is triggering excessive iterations.
|
||||
* Will remove the autofill inline menu if any set mutation observer is
|
||||
* triggering excessive iterations.
|
||||
*/
|
||||
private isTriggeringExcessiveMutationObserverIterations() {
|
||||
const resetCounters = () => {
|
||||
this.mutationObserverIterations = 0;
|
||||
this.foreignMutationsCount = 0;
|
||||
};
|
||||
|
||||
if (this.mutationObserverIterationsResetTimeout) {
|
||||
clearTimeout(this.mutationObserverIterationsResetTimeout);
|
||||
}
|
||||
|
||||
this.mutationObserverIterations++;
|
||||
this.mutationObserverIterationsResetTimeout = setTimeout(() => resetCounters(), 2000);
|
||||
|
||||
if (this.mutationObserverIterations > 20) {
|
||||
clearTimeout(this.mutationObserverIterationsResetTimeout);
|
||||
resetCounters();
|
||||
this.forceCloseInlineMenu();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { AutofillInlineMenuListIframe } from "./autofill-inline-menu-list-iframe";
|
||||
|
||||
describe("AutofillInlineMenuListIframe", () => {
|
||||
window.customElements.define(
|
||||
"autofill-inline-menu-list-iframe",
|
||||
class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
new AutofillInlineMenuListIframe(this);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a custom element that is an instance of the AutofillIframeElement parent class", () => {
|
||||
document.body.innerHTML =
|
||||
"<autofill-inline-menu-list-iframe></autofill-inline-menu-list-iframe>";
|
||||
|
||||
const iframe = document.querySelector("autofill-inline-menu-list-iframe");
|
||||
|
||||
expect(iframe).toBeInstanceOf(HTMLElement);
|
||||
expect(iframe.shadowRoot).toBeDefined();
|
||||
});
|
||||
});
|
@ -0,0 +1,23 @@
|
||||
import { AutofillOverlayPort } from "../../../enums/autofill-overlay.enum";
|
||||
|
||||
import { AutofillInlineMenuIframeElement } from "./autofill-inline-menu-iframe-element";
|
||||
|
||||
export class AutofillInlineMenuListIframe extends AutofillInlineMenuIframeElement {
|
||||
constructor(element: HTMLElement) {
|
||||
super(
|
||||
element,
|
||||
AutofillOverlayPort.List,
|
||||
{
|
||||
height: "0px",
|
||||
minWidth: "250px",
|
||||
maxHeight: "180px",
|
||||
boxShadow: "rgba(0, 0, 0, 0.1) 2px 4px 6px 0px",
|
||||
borderRadius: "4px",
|
||||
borderWidth: "1px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "rgb(206, 212, 220)",
|
||||
},
|
||||
chrome.i18n.getMessage("bitwardenVault"),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AutofillInlineMenuButton initAutofillInlineMenuButton creates the button element with the locked icon when the user's auth status is not Unlocked 1`] = `
|
||||
<button
|
||||
aria-label="toggleBitwardenVaultOverlay"
|
||||
class="inline-menu-button"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="inline-menu-button-svg-icon logo-locked-icon"
|
||||
fill="none"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
d="M12.66.175A.566.566 0 0 0 12.25 0H1.75a.559.559 0 0 0-.409.175.561.561 0 0 0-.175.41v7c.002.532.105 1.06.305 1.554.189.488.444.948.756 1.368.322.42.682.81 1.076 1.163.365.335.75.649 1.152.939.35.248.718.483 1.103.706.385.222.656.372.815.45.16.08.29.141.386.182A.53.53 0 0 0 7 14a.509.509 0 0 0 .238-.055c.098-.043.225-.104.387-.182.162-.079.438-.23.816-.45.378-.222.75-.459 1.102-.707.403-.29.788-.604 1.154-.939a8.435 8.435 0 0 0 1.076-1.163c.312-.42.567-.88.757-1.367a4.19 4.19 0 0 0 .304-1.555v-7a.55.55 0 0 0-.174-.407Z"
|
||||
fill="#175DDC"
|
||||
/>
|
||||
<path
|
||||
d="M7 12.365s4.306-2.18 4.306-4.717V1.5H7v10.865Z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<circle
|
||||
cx="12.889"
|
||||
cy="12.889"
|
||||
fill="#F8F9FA"
|
||||
r="4.889"
|
||||
/>
|
||||
<path
|
||||
d="M11.26 11.717h2.37v-.848c0-.313-.116-.58-.348-.8a1.17 1.17 0 0 0-.838-.332c-.327 0-.606.11-.838.332a1.066 1.066 0 0 0-.347.8v.848Zm3.851.424v2.546a.4.4 0 0 1-.13.3.44.44 0 0 1-.314.124h-4.445a.44.44 0 0 1-.315-.124.4.4 0 0 1-.13-.3V12.14a.4.4 0 0 1 .13-.3.44.44 0 0 1 .315-.124h.148v-.848c0-.542.204-1.008.612-1.397a2.044 2.044 0 0 1 1.462-.583c.568 0 1.056.194 1.463.583.408.39.611.855.611 1.397v.848h.149a.44.44 0 0 1 .315.124.4.4 0 0 1 .13.3Z"
|
||||
fill="#555"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<rect
|
||||
fill="#fff"
|
||||
height="16"
|
||||
rx="2"
|
||||
width="16"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`AutofillInlineMenuButton initAutofillInlineMenuButton creates the button element with the normal icon when the user's auth status is Unlocked 1`] = `
|
||||
<button
|
||||
aria-label="toggleBitwardenVaultOverlay"
|
||||
class="inline-menu-button"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="inline-menu-button-svg-icon logo-icon"
|
||||
fill="none"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
width="14"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.66.175A.566.566 0 0 0 12.25 0H1.75a.559.559 0 0 0-.409.175.561.561 0 0 0-.175.41v7c.002.532.105 1.06.305 1.554.189.488.444.948.756 1.368.322.42.682.81 1.076 1.163.365.335.75.649 1.152.939.35.248.718.483 1.103.706.385.222.656.372.815.45.16.08.29.141.386.182A.53.53 0 0 0 7 14a.509.509 0 0 0 .238-.055c.098-.043.225-.104.387-.182.162-.079.438-.23.816-.45.378-.222.75-.459 1.102-.707.403-.29.788-.604 1.154-.939a8.435 8.435 0 0 0 1.076-1.163c.312-.42.567-.88.757-1.367a4.19 4.19 0 0 0 .304-1.555v-7a.55.55 0 0 0-.174-.407Z"
|
||||
fill="#175DDC"
|
||||
/>
|
||||
<path
|
||||
d="M7 12.365s4.306-2.18 4.306-4.717V1.5H7v10.865Z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
@ -0,0 +1,133 @@
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
import { createInitAutofillInlineMenuButtonMessageMock } from "../../../../spec/autofill-mocks";
|
||||
import { flushPromises, postWindowMessage } from "../../../../spec/testing-utils";
|
||||
|
||||
import { AutofillInlineMenuButton } from "./autofill-inline-menu-button";
|
||||
|
||||
describe("AutofillInlineMenuButton", () => {
|
||||
globalThis.customElements.define("autofill-inline-menu-button", AutofillInlineMenuButton);
|
||||
|
||||
let autofillInlineMenuButton: AutofillInlineMenuButton;
|
||||
const portKey: string = "inlineMenuButtonPortKey";
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `<autofill-inline-menu-button></autofill-inline-menu-button>`;
|
||||
autofillInlineMenuButton = document.querySelector("autofill-inline-menu-button");
|
||||
autofillInlineMenuButton["messageOrigin"] = "https://localhost/";
|
||||
jest.spyOn(globalThis.document, "createElement");
|
||||
jest.spyOn(globalThis.parent, "postMessage");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("initAutofillInlineMenuButton", () => {
|
||||
it("creates the button element with the locked icon when the user's auth status is not Unlocked", async () => {
|
||||
postWindowMessage(
|
||||
createInitAutofillInlineMenuButtonMessageMock({
|
||||
authStatus: AuthenticationStatus.Locked,
|
||||
portKey,
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(autofillInlineMenuButton["buttonElement"]).toMatchSnapshot();
|
||||
expect(autofillInlineMenuButton["buttonElement"].querySelector("svg")).toBe(
|
||||
autofillInlineMenuButton["logoLockedIconElement"],
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the button element with the normal icon when the user's auth status is Unlocked ", async () => {
|
||||
postWindowMessage(createInitAutofillInlineMenuButtonMessageMock({ portKey }));
|
||||
await flushPromises();
|
||||
|
||||
expect(autofillInlineMenuButton["buttonElement"]).toMatchSnapshot();
|
||||
expect(autofillInlineMenuButton["buttonElement"].querySelector("svg")).toBe(
|
||||
autofillInlineMenuButton["logoIconElement"],
|
||||
);
|
||||
});
|
||||
|
||||
it("posts a message to the background indicating that the icon was clicked", async () => {
|
||||
postWindowMessage(createInitAutofillInlineMenuButtonMessageMock({ portKey }));
|
||||
await flushPromises();
|
||||
|
||||
autofillInlineMenuButton["buttonElement"].click();
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "autofillInlineMenuButtonClicked", portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("global event listeners", () => {
|
||||
beforeEach(() => {
|
||||
postWindowMessage(createInitAutofillInlineMenuButtonMessageMock({ portKey }));
|
||||
});
|
||||
|
||||
it("does not post a message to close the autofill inline menu if the element is focused during the focus check", async () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
|
||||
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" });
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith({
|
||||
command: "triggerDelayedAutofillInlineMenuClosure",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not post a message to close the autofill inline menu if the button element is hovered", async () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
|
||||
jest.spyOn(autofillInlineMenuButton["buttonElement"], "matches").mockReturnValue(true);
|
||||
|
||||
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" });
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.parent.postMessage).not.toHaveBeenCalledWith({
|
||||
command: "triggerDelayedAutofillInlineMenuClosure",
|
||||
});
|
||||
});
|
||||
|
||||
it("posts a message to close the autofill inline menu if the element is not focused during the focus check", async () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
|
||||
jest.spyOn(autofillInlineMenuButton["buttonElement"], "matches").mockReturnValue(false);
|
||||
|
||||
postWindowMessage({ command: "checkAutofillInlineMenuButtonFocused" });
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "triggerDelayedAutofillInlineMenuClosure", portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the user's auth status", async () => {
|
||||
autofillInlineMenuButton["authStatus"] = AuthenticationStatus.Locked;
|
||||
|
||||
postWindowMessage({
|
||||
command: "updateAutofillInlineMenuButtonAuthStatus",
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(autofillInlineMenuButton["authStatus"]).toBe(AuthenticationStatus.Unlocked);
|
||||
});
|
||||
|
||||
it("updates the page color scheme meta tag", async () => {
|
||||
const colorSchemeMetaTag = globalThis.document.createElement("meta");
|
||||
colorSchemeMetaTag.setAttribute("name", "color-scheme");
|
||||
colorSchemeMetaTag.setAttribute("content", "light");
|
||||
globalThis.document.head.append(colorSchemeMetaTag);
|
||||
|
||||
postWindowMessage({
|
||||
command: "updateAutofillInlineMenuColorScheme",
|
||||
colorScheme: "dark",
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(colorSchemeMetaTag.getAttribute("content")).toBe("dark");
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,126 @@
|
||||
import "@webcomponents/custom-elements";
|
||||
import "lit/polyfill-support.js";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import { buildSvgDomElement } from "../../../../utils";
|
||||
import { logoIcon, logoLockedIcon } from "../../../../utils/svg-icons";
|
||||
import {
|
||||
InitAutofillInlineMenuButtonMessage,
|
||||
AutofillInlineMenuButtonMessage,
|
||||
AutofillInlineMenuButtonWindowMessageHandlers,
|
||||
} from "../../abstractions/autofill-inline-menu-button";
|
||||
import { AutofillInlineMenuPageElement } from "../shared/autofill-inline-menu-page-element";
|
||||
|
||||
export class AutofillInlineMenuButton extends AutofillInlineMenuPageElement {
|
||||
private authStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut;
|
||||
private readonly buttonElement: HTMLButtonElement;
|
||||
private readonly logoIconElement: HTMLElement;
|
||||
private readonly logoLockedIconElement: HTMLElement;
|
||||
private readonly inlineMenuButtonWindowMessageHandlers: AutofillInlineMenuButtonWindowMessageHandlers =
|
||||
{
|
||||
initAutofillInlineMenuButton: ({ message }) => this.initAutofillInlineMenuButton(message),
|
||||
checkAutofillInlineMenuButtonFocused: () => this.checkButtonFocused(),
|
||||
updateAutofillInlineMenuButtonAuthStatus: ({ message }) =>
|
||||
this.updateAuthStatus(message.authStatus),
|
||||
updateAutofillInlineMenuColorScheme: ({ message }) => this.updatePageColorScheme(message),
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.buttonElement = globalThis.document.createElement("button");
|
||||
|
||||
this.setupGlobalListeners(this.inlineMenuButtonWindowMessageHandlers);
|
||||
|
||||
this.logoIconElement = buildSvgDomElement(logoIcon);
|
||||
this.logoIconElement.classList.add("inline-menu-button-svg-icon", "logo-icon");
|
||||
|
||||
this.logoLockedIconElement = buildSvgDomElement(logoLockedIcon);
|
||||
this.logoLockedIconElement.classList.add("inline-menu-button-svg-icon", "logo-locked-icon");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the inline menu button. Facilitates ensuring that the page
|
||||
* is set up with the expected styles and translations.
|
||||
*
|
||||
* @param authStatus - The authentication status of the user
|
||||
* @param styleSheetUrl - The URL of the stylesheet to apply to the page
|
||||
* @param translations - The translations to apply to the page
|
||||
* @param portKey - Background generated key that allows the port to communicate with the background
|
||||
*/
|
||||
private async initAutofillInlineMenuButton({
|
||||
authStatus,
|
||||
styleSheetUrl,
|
||||
translations,
|
||||
portKey,
|
||||
}: InitAutofillInlineMenuButtonMessage) {
|
||||
const linkElement = await this.initAutofillInlineMenuPage(
|
||||
"button",
|
||||
styleSheetUrl,
|
||||
translations,
|
||||
portKey,
|
||||
);
|
||||
this.buttonElement.tabIndex = -1;
|
||||
this.buttonElement.type = "button";
|
||||
this.buttonElement.classList.add("inline-menu-button");
|
||||
this.buttonElement.setAttribute(
|
||||
"aria-label",
|
||||
this.getTranslation("toggleBitwardenVaultOverlay"),
|
||||
);
|
||||
this.buttonElement.addEventListener(EVENTS.CLICK, this.handleButtonElementClick);
|
||||
this.postMessageToParent({ command: "updateAutofillInlineMenuColorScheme" });
|
||||
|
||||
this.updateAuthStatus(authStatus);
|
||||
|
||||
this.shadowDom.append(linkElement, this.buttonElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the authentication status of the user. This will update the icon
|
||||
* displayed on the button.
|
||||
*
|
||||
* @param authStatus - The authentication status of the user
|
||||
*/
|
||||
private updateAuthStatus(authStatus: AuthenticationStatus) {
|
||||
this.authStatus = authStatus;
|
||||
|
||||
this.buttonElement.innerHTML = "";
|
||||
const iconElement =
|
||||
this.authStatus === AuthenticationStatus.Unlocked
|
||||
? this.logoIconElement
|
||||
: this.logoLockedIconElement;
|
||||
this.buttonElement.append(iconElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles updating the page color scheme meta tag. Ensures that the button
|
||||
* does not present with a non-transparent background on dark mode pages.
|
||||
*
|
||||
* @param colorScheme - The color scheme of the iframe's parent page
|
||||
*/
|
||||
private updatePageColorScheme({ colorScheme }: AutofillInlineMenuButtonMessage) {
|
||||
const colorSchemeMetaTag = globalThis.document.querySelector("meta[name='color-scheme']");
|
||||
colorSchemeMetaTag?.setAttribute("content", colorScheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a click event on the button element. Posts a message to the
|
||||
* parent window indicating that the button was clicked.
|
||||
*/
|
||||
private handleButtonElementClick = () => {
|
||||
this.postMessageToParent({ command: "autofillInlineMenuButtonClicked" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the button is focused. If it is not, then it posts a message
|
||||
* to the parent window indicating that the inline menu should be closed.
|
||||
*/
|
||||
private checkButtonFocused() {
|
||||
if (globalThis.document.hasFocus() || this.buttonElement.matches(":hover")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.postMessageToParent({ command: "triggerDelayedAutofillInlineMenuClosure" });
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum";
|
||||
|
||||
import { AutofillInlineMenuButton } from "./autofill-inline-menu-button";
|
||||
|
||||
require("./button.scss");
|
||||
|
||||
(function () {
|
||||
globalThis.customElements.define(AutofillOverlayElement.Button, AutofillInlineMenuButton);
|
||||
})();
|
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Bitwarden inline menu button</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="normal" />
|
||||
</head>
|
||||
<body>
|
||||
<autofill-inline-menu-button></autofill-inline-menu-button>
|
||||
</body>
|
||||
</html>
|
@ -1,4 +1,4 @@
|
||||
@import "../../../shared/styles/variables";
|
||||
@import "../../../../shared/styles/variables";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@ -14,12 +14,12 @@ body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
autofill-overlay-button {
|
||||
autofill-inline-menu-button {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.overlay-button {
|
||||
.inline-menu-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
@ -28,7 +28,7 @@ autofill-overlay-button {
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
.overlay-button-svg-icon {
|
||||
.inline-menu-button-svg-icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
@ -0,0 +1,536 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with an empty list of ciphers creates the views for the no results inline menu 1`] = `
|
||||
<div
|
||||
class="inline-menu-list-container theme_light"
|
||||
>
|
||||
<div
|
||||
class="no-items inline-menu-list-message"
|
||||
>
|
||||
noItemsToShow
|
||||
</div>
|
||||
<div
|
||||
class="inline-menu-list-button-container"
|
||||
>
|
||||
<button
|
||||
aria-label="addNewVaultItem, opensInANewWindow"
|
||||
class="add-new-item-button inline-menu-list-button"
|
||||
id="new-item-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
d="M15.222 7.914H8.963a.471.471 0 0 1-.34-.147.512.512 0 0 1-.142-.353V.99c0-.133-.05-.26-.14-.354a.471.471 0 0 0-.68 0 .51.51 0 0 0-.142.354v6.424c0 .132-.051.26-.142.353a.474.474 0 0 1-.34.147H.777a.47.47 0 0 0-.34.146.5.5 0 0 0-.14.354.522.522 0 0 0 .14.353.48.48 0 0 0 .34.147h6.26c.128 0 .25.052.34.146.09.094.142.221.142.354v6.576c0 .132.05.26.14.353a.471.471 0 0 0 .68 0 .512.512 0 0 0 .142-.353V9.414c0-.133.051-.26.142-.354a.474.474 0 0 1 .34-.146h6.26c.127 0 .25-.053.34-.147a.511.511 0 0 0 0-.707.472.472 0 0 0-.34-.146Z"
|
||||
fill="#175DDC"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 .49h16v16H0z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
newItem
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user creates the view for a list of ciphers 1`] = `
|
||||
<div
|
||||
class="inline-menu-list-container theme_light"
|
||||
>
|
||||
<ul
|
||||
class="inline-menu-list-actions"
|
||||
role="list"
|
||||
>
|
||||
<li
|
||||
class="inline-menu-list-actions-item"
|
||||
role="listitem"
|
||||
>
|
||||
<div
|
||||
class="cipher-container"
|
||||
>
|
||||
<button
|
||||
aria-description="username, username1"
|
||||
aria-label="fillCredentialsFor website login 1"
|
||||
class="fill-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="cipher-icon"
|
||||
style="background-image: url(https://jest-testing-website.com/image.png);"
|
||||
/>
|
||||
<span
|
||||
class="cipher-details"
|
||||
>
|
||||
<span
|
||||
class="cipher-name"
|
||||
title="website login 1"
|
||||
>
|
||||
website login 1
|
||||
</span>
|
||||
<span
|
||||
class="cipher-user-login"
|
||||
title="username1"
|
||||
>
|
||||
username1
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="view website login 1, opensInANewWindow"
|
||||
class="view-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
|
||||
fill="#175DDC"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 .113h20v19.773H0z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
class="inline-menu-list-actions-item"
|
||||
role="listitem"
|
||||
>
|
||||
<div
|
||||
class="cipher-container"
|
||||
>
|
||||
<button
|
||||
aria-description="username, username2"
|
||||
aria-label="fillCredentialsFor website login 2"
|
||||
class="fill-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="cipher-icon bwi bw-icon"
|
||||
/>
|
||||
<span
|
||||
class="cipher-details"
|
||||
>
|
||||
<span
|
||||
class="cipher-name"
|
||||
title="website login 2"
|
||||
>
|
||||
website login 2
|
||||
</span>
|
||||
<span
|
||||
class="cipher-user-login"
|
||||
title="username2"
|
||||
>
|
||||
username2
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="view website login 2, opensInANewWindow"
|
||||
class="view-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
|
||||
fill="#175DDC"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 .113h20v19.773H0z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
class="inline-menu-list-actions-item"
|
||||
role="listitem"
|
||||
>
|
||||
<div
|
||||
class="cipher-container"
|
||||
>
|
||||
<button
|
||||
aria-description="username, "
|
||||
aria-label="fillCredentialsFor "
|
||||
class="fill-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="cipher-icon bwi bw-icon"
|
||||
/>
|
||||
<span
|
||||
class="cipher-details"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
aria-label="view , opensInANewWindow"
|
||||
class="view-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
|
||||
fill="#175DDC"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 .113h20v19.773H0z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
class="inline-menu-list-actions-item"
|
||||
role="listitem"
|
||||
>
|
||||
<div
|
||||
class="cipher-container"
|
||||
>
|
||||
<button
|
||||
aria-description="username, username4"
|
||||
aria-label="fillCredentialsFor website login 4"
|
||||
class="fill-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="cipher-icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M18.026 17.842c-1.418 1.739-3.517 2.84-5.86 2.84a7.364 7.364 0 0 1-3.431-.848l.062-.15.062-.151.063-.157c.081-.203.17-.426.275-.646.133-.28.275-.522.426-.68.026-.028.101-.075.275-.115.165-.037.376-.059.629-.073.138-.008.288-.014.447-.02.399-.016.847-.034 1.266-.092.314-.044.566-.131.755-.271a.884.884 0 0 0 .352-.555c.037-.2.008-.392-.03-.543-.035-.135-.084-.264-.12-.355l-.01-.03a4.26 4.26 0 0 0-.145-.33c-.126-.264-.237-.497-.288-1.085-.03-.344.09-.73.251-1.138l.089-.22c.05-.123.1-.247.14-.355.064-.171.129-.375.129-.566a1.51 1.51 0 0 0-.134-.569 2.573 2.573 0 0 0-.319-.547c-.246-.323-.635-.669-1.093-.669-.44 0-1.006.169-1.487.368-.246.102-.48.216-.68.33-.192.111-.372.235-.492.359-.93.96-1.48 1.239-1.81 1.258-.277.017-.478-.15-.736-.525a9.738 9.738 0 0 1-.19-.29l-.006-.01a11.568 11.568 0 0 0-.198-.305 2.76 2.76 0 0 0-.521-.6 1.39 1.39 0 0 0-1.088-.314 8.302 8.302 0 0 1 1.987-3.936c.055.342.146.626.272.856.23.42.561.64.926.716.406.086.857-.061 1.26-.216.125-.047.248-.097.372-.147.309-.125.618-.25.947-.341.26-.072.581-.057.959.012.264.049.529.118.8.19l.36.091c.379.094.782.178 1.135.148.374-.032.733-.197.934-.623a.874.874 0 0 0 .024-.752c-.087-.197-.24-.355-.35-.47-.26-.267-.412-.427-.412-.685 0-.125.037-.2.09-.263a.982.982 0 0 1 .303-.211c.059-.03.119-.058.183-.089l.036-.016a3.79 3.79 0 0 0 .236-.118c.047-.026.098-.056.148-.093 1.936.747 3.51 2.287 4.368 4.249a7.739 7.739 0 0 0-.031-.004c-.38-.047-.738-.056-1.063.061-.34.123-.603.368-.817.74-.122.211-.284.43-.463.67l-.095.129c-.207.281-.431.595-.58.92-.15.326-.245.705-.142 1.103.104.397.387.738.837 1.036.099.065.225.112.314.145l.02.008c.108.04.195.074.268.117.07.042.106.08.124.114.017.03.037.087.022.206-.047.376-.069.73-.052 1.034.017.292.071.59.218.809.118.174.12.421.108.786v.01a2.46 2.46 0 0 0 .021.518.809.809 0 0 0 .15.35Zm1.357.059a9.654 9.654 0 0 0 1.62-5.386c0-5.155-3.957-9.334-8.836-9.334-4.88 0-8.836 4.179-8.836 9.334 0 3.495 1.82 6.543 4.513 8.142v.093h.161a8.426 8.426 0 0 0 4.162 1.098c2.953 0 5.568-1.53 7.172-3.882a.569.569 0 0 0 .048-.062l-.004-.003ZM8.152 19.495a43.345 43.345 0 0 1 .098-.238l.057-.142c.082-.205.182-.455.297-.698.143-.301.323-.624.552-.864.163-.172.392-.254.602-.302.219-.05.473-.073.732-.088.162-.01.328-.016.495-.023.386-.015.782-.03 1.168-.084.255-.036.392-.099.461-.15.06-.045.076-.084.083-.12a.534.534 0 0 0-.02-.223 2.552 2.552 0 0 0-.095-.278l-.01-.027a3.128 3.128 0 0 0-.104-.232c-.134-.282-.31-.65-.374-1.381-.046-.533.138-1.063.3-1.472.035-.09.069-.172.1-.249.046-.11.086-.21.123-.31.062-.169.083-.264.083-.312a.812.812 0 0 0-.076-.283 1.867 1.867 0 0 0-.23-.394c-.21-.274-.428-.408-.577-.408-.315 0-.788.13-1.246.32a5.292 5.292 0 0 0-.603.293 1.727 1.727 0 0 0-.347.244c-.936.968-1.641 1.421-2.235 1.457-.646.04-1.036-.413-1.31-.813-.07-.103-.139-.21-.203-.311l-.005-.007c-.064-.101-.125-.197-.188-.29a2.098 2.098 0 0 0-.387-.453.748.748 0 0 0-.436-.18c-.1-.006-.22.005-.365.046a8.707 8.707 0 0 0-.056.992c0 2.957 1.488 5.547 3.716 6.98Zm10.362-2.316.003-.192.002-.046c.01-.305.026-.786-.232-1.169-.036-.054-.082-.189-.096-.444-.014-.243.003-.55.047-.9a1.051 1.051 0 0 0-.105-.649.987.987 0 0 0-.374-.374 2.285 2.285 0 0 0-.367-.166h-.003a1.243 1.243 0 0 1-.205-.088c-.369-.244-.505-.46-.549-.629-.044-.168-.015-.364.099-.61.115-.25.297-.511.507-.796l.089-.12c.178-.239.368-.495.512-.745.152-.263.302-.382.466-.441.18-.065.416-.073.77-.03.142.018.275.04.397.063.274.837.423 1.736.423 2.671a8.45 8.45 0 0 1-1.384 4.665Zm-4.632-12.63a7.362 7.362 0 0 0-1.715-.201c-1.89 0-3.621.716-4.965 1.905.025.54.12.887.24 1.105.13.238.295.34.482.38.2.042.484-.027.905-.188l.328-.13c.32-.13.681-.275 1.048-.377.398-.111.833-.075 1.24 0 .289.053.59.132.871.205l.326.084c.383.094.694.151.932.13.216-.017.326-.092.395-.237.039-.083.027-.114.014-.144-.027-.062-.088-.136-.212-.264l-.043-.044c-.218-.222-.567-.578-.567-1.142 0-.305.101-.547.262-.734.137-.159.308-.267.46-.347Z"
|
||||
fill="#777"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
class="cipher-details"
|
||||
>
|
||||
<span
|
||||
class="cipher-name"
|
||||
title="website login 4"
|
||||
>
|
||||
website login 4
|
||||
</span>
|
||||
<span
|
||||
class="cipher-user-login"
|
||||
title="username4"
|
||||
>
|
||||
username4
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="view website login 4, opensInANewWindow"
|
||||
class="view-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
|
||||
fill="#175DDC"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 .113h20v19.773H0z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
class="inline-menu-list-actions-item"
|
||||
role="listitem"
|
||||
>
|
||||
<div
|
||||
class="cipher-container"
|
||||
>
|
||||
<button
|
||||
aria-description="username, username5"
|
||||
aria-label="fillCredentialsFor website login 5"
|
||||
class="fill-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="cipher-icon"
|
||||
style="background-image: url(https://jest-testing-website.com/image.png);"
|
||||
/>
|
||||
<span
|
||||
class="cipher-details"
|
||||
>
|
||||
<span
|
||||
class="cipher-name"
|
||||
title="website login 5"
|
||||
>
|
||||
website login 5
|
||||
</span>
|
||||
<span
|
||||
class="cipher-user-login"
|
||||
title="username5"
|
||||
>
|
||||
username5
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="view website login 5, opensInANewWindow"
|
||||
class="view-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
|
||||
fill="#175DDC"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 .113h20v19.773H0z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
class="inline-menu-list-actions-item"
|
||||
role="listitem"
|
||||
>
|
||||
<div
|
||||
class="cipher-container"
|
||||
>
|
||||
<button
|
||||
aria-description="username, username6"
|
||||
aria-label="fillCredentialsFor website login 6"
|
||||
class="fill-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="cipher-icon"
|
||||
style="background-image: url(https://jest-testing-website.com/image.png);"
|
||||
/>
|
||||
<span
|
||||
class="cipher-details"
|
||||
>
|
||||
<span
|
||||
class="cipher-name"
|
||||
title="website login 6"
|
||||
>
|
||||
website login 6
|
||||
</span>
|
||||
<span
|
||||
class="cipher-user-login"
|
||||
title="username6"
|
||||
>
|
||||
username6
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="view website login 6, opensInANewWindow"
|
||||
class="view-cipher-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
|
||||
fill="#175DDC"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 .113h20v19.773H0z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AutofillInlineMenuList initAutofillInlineMenuList the locked inline menu for an unauthenticated user creates the views for the locked inline menu 1`] = `
|
||||
<div
|
||||
class="inline-menu-list-container theme_light"
|
||||
>
|
||||
<div
|
||||
class="locked-inline-menu inline-menu-list-message"
|
||||
id="locked-inline-menu-description"
|
||||
>
|
||||
unlockYourAccount
|
||||
</div>
|
||||
<div
|
||||
class="inline-menu-list-button-container"
|
||||
>
|
||||
<button
|
||||
aria-label="unlockAccount, opensInANewWindow"
|
||||
class="unlock-button inline-menu-list-button"
|
||||
id="unlock-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="17"
|
||||
viewBox="0 0 17 17"
|
||||
width="17"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
d="M8.799 11.633a.68.68 0 0 0-.639.422.695.695 0 0 0-.054.264.682.682 0 0 0 .374.6v1.13a.345.345 0 1 0 .693 0v-1.17a.68.68 0 0 0 .315-.56.695.695 0 0 0-.204-.486.682.682 0 0 0-.485-.2Zm4.554-4.657h-7.11a.438.438 0 0 1-.406-.26A3.81 3.81 0 0 1 5.584 4.3c.112-.435.312-.842.588-1.195A3.196 3.196 0 0 1 7.19 2.25a3.468 3.468 0 0 1 3.225-.059A3.62 3.62 0 0 1 11.94 3.71l.327.59a.502.502 0 1 0 .885-.483l-.307-.552a4.689 4.689 0 0 0-2.209-2.078 4.466 4.466 0 0 0-3.936.185A4.197 4.197 0 0 0 5.37 2.49a4.234 4.234 0 0 0-.768 1.565 4.714 4.714 0 0 0 .162 2.682.182.182 0 0 1-.085.22.173.173 0 0 1-.082.02h-.353a1.368 1.368 0 0 0-1.277.842c-.07.168-.107.348-.109.53v7.1a1.392 1.392 0 0 0 .412.974 1.352 1.352 0 0 0 .974.394h9.117c.363.001.711-.142.97-.4a1.39 1.39 0 0 0 .407-.972v-7.1a1.397 1.397 0 0 0-.414-.973 1.368 1.368 0 0 0-.972-.396Zm.37 8.469a.373.373 0 0 1-.11.26.364.364 0 0 1-.26.107H4.246a.366.366 0 0 1-.26-.107.374.374 0 0 1-.11-.261V8.349a.374.374 0 0 1 .11-.26.366.366 0 0 1 .26-.108h9.116a.366.366 0 0 1 .37.367l-.008 7.097Z"
|
||||
fill="#175DDC"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M.798.817h16v16h-16z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
unlockAccount
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,491 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
import { createInitAutofillInlineMenuListMessageMock } from "../../../../spec/autofill-mocks";
|
||||
import { flushPromises, postWindowMessage } from "../../../../spec/testing-utils";
|
||||
|
||||
import { AutofillInlineMenuList } from "./autofill-inline-menu-list";
|
||||
|
||||
describe("AutofillInlineMenuList", () => {
|
||||
globalThis.customElements.define("autofill-inline-menu-list", AutofillInlineMenuList);
|
||||
global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
|
||||
let autofillInlineMenuList: AutofillInlineMenuList;
|
||||
const portKey: string = "inlineMenuListPortKey";
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `<autofill-inline-menu-list></autofill-inline-menu-list>`;
|
||||
autofillInlineMenuList = document.querySelector("autofill-inline-menu-list");
|
||||
jest.spyOn(globalThis.document, "createElement");
|
||||
jest.spyOn(globalThis.parent, "postMessage");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("initAutofillInlineMenuList", () => {
|
||||
describe("the locked inline menu for an unauthenticated user", () => {
|
||||
beforeEach(() => {
|
||||
postWindowMessage(
|
||||
createInitAutofillInlineMenuListMessageMock({
|
||||
authStatus: AuthenticationStatus.Locked,
|
||||
cipherList: [],
|
||||
portKey,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the views for the locked inline menu", () => {
|
||||
expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows the user to unlock the vault", () => {
|
||||
const unlockButton =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector("#unlock-button");
|
||||
|
||||
unlockButton.dispatchEvent(new Event("click"));
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "unlockVault", portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the inline menu with an empty list of ciphers", () => {
|
||||
beforeEach(() => {
|
||||
postWindowMessage(
|
||||
createInitAutofillInlineMenuListMessageMock({
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
ciphers: [],
|
||||
portKey,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the views for the no results inline menu", () => {
|
||||
expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows the user to add a vault item", () => {
|
||||
const addVaultItemButton =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector("#new-item-button");
|
||||
|
||||
addVaultItemButton.dispatchEvent(new Event("click"));
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "addNewVaultItem", portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the list of ciphers for an authenticated user", () => {
|
||||
beforeEach(() => {
|
||||
postWindowMessage(createInitAutofillInlineMenuListMessageMock());
|
||||
});
|
||||
|
||||
it("creates the view for a list of ciphers", () => {
|
||||
expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("loads ciphers on scroll one page at a time", () => {
|
||||
jest.useFakeTimers();
|
||||
const originalListOfElements =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll(".cipher-container");
|
||||
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.runAllTimers();
|
||||
|
||||
const updatedListOfElements =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll(".cipher-container");
|
||||
|
||||
expect(originalListOfElements.length).toBe(6);
|
||||
expect(updatedListOfElements.length).toBe(8);
|
||||
});
|
||||
|
||||
it("debounces the ciphers scroll handler", () => {
|
||||
jest.useFakeTimers();
|
||||
autofillInlineMenuList["cipherListScrollDebounceTimeout"] = setTimeout(jest.fn, 0);
|
||||
const handleDebouncedScrollEventSpy = jest.spyOn(
|
||||
autofillInlineMenuList as any,
|
||||
"handleDebouncedScrollEvent",
|
||||
);
|
||||
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(100);
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(100);
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(400);
|
||||
|
||||
expect(handleDebouncedScrollEventSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("fill cipher button event listeners", () => {
|
||||
beforeEach(() => {
|
||||
postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey }));
|
||||
});
|
||||
|
||||
it("allows the user to fill a cipher on click", () => {
|
||||
const fillCipherButton =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector(".fill-cipher-button");
|
||||
|
||||
fillCipherButton.dispatchEvent(new Event("click"));
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "fillAutofillInlineMenuCipher", inlineMenuCipherId: "1", portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows the user to move keyboard focus to the next cipher element on ArrowDown", () => {
|
||||
const fillCipherElements =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll(
|
||||
".fill-cipher-button",
|
||||
);
|
||||
const firstFillCipherElement = fillCipherElements[0];
|
||||
const secondFillCipherElement = fillCipherElements[1];
|
||||
jest.spyOn(secondFillCipherElement as HTMLElement, "focus");
|
||||
|
||||
firstFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
|
||||
|
||||
expect((secondFillCipherElement as HTMLElement).focus).toBeCalled();
|
||||
});
|
||||
|
||||
it("directs focus to the first item in the cipher list if no cipher is present after the current one when pressing ArrowDown", () => {
|
||||
const fillCipherElements =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll(
|
||||
".fill-cipher-button",
|
||||
);
|
||||
const lastFillCipherElement = fillCipherElements[fillCipherElements.length - 1];
|
||||
const firstFillCipherElement = fillCipherElements[0];
|
||||
jest.spyOn(firstFillCipherElement as HTMLElement, "focus");
|
||||
|
||||
lastFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
|
||||
|
||||
expect((firstFillCipherElement as HTMLElement).focus).toBeCalled();
|
||||
});
|
||||
|
||||
it("allows the user to move keyboard focus to the previous cipher element on ArrowUp", () => {
|
||||
const fillCipherElements =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll(
|
||||
".fill-cipher-button",
|
||||
);
|
||||
const firstFillCipherElement = fillCipherElements[0];
|
||||
const secondFillCipherElement = fillCipherElements[1];
|
||||
jest.spyOn(firstFillCipherElement as HTMLElement, "focus");
|
||||
|
||||
secondFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
|
||||
|
||||
expect((firstFillCipherElement as HTMLElement).focus).toBeCalled();
|
||||
});
|
||||
|
||||
it("directs focus to the last item in the cipher list if no cipher is present before the current one when pressing ArrowUp", () => {
|
||||
const fillCipherElements =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll(
|
||||
".fill-cipher-button",
|
||||
);
|
||||
const firstFillCipherElement = fillCipherElements[0];
|
||||
const lastFillCipherElement = fillCipherElements[fillCipherElements.length - 1];
|
||||
jest.spyOn(lastFillCipherElement as HTMLElement, "focus");
|
||||
|
||||
firstFillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
|
||||
|
||||
expect((lastFillCipherElement as HTMLElement).focus).toBeCalled();
|
||||
});
|
||||
|
||||
it("allows the user to move keyboard focus to the view cipher button on ArrowRight", () => {
|
||||
const cipherContainerElement =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector(".cipher-container");
|
||||
const fillCipherElement = cipherContainerElement.querySelector(".fill-cipher-button");
|
||||
const viewCipherButton = cipherContainerElement.querySelector(".view-cipher-button");
|
||||
jest.spyOn(viewCipherButton as HTMLElement, "focus");
|
||||
|
||||
fillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowRight" }));
|
||||
|
||||
expect((viewCipherButton as HTMLElement).focus).toBeCalled();
|
||||
});
|
||||
|
||||
it("ignores keyup events that do not include ArrowUp, ArrowDown, or ArrowRight", () => {
|
||||
const fillCipherElement =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector(".fill-cipher-button");
|
||||
jest.spyOn(fillCipherElement as HTMLElement, "focus");
|
||||
|
||||
fillCipherElement.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowLeft" }));
|
||||
|
||||
expect((fillCipherElement as HTMLElement).focus).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("view cipher button event listeners", () => {
|
||||
beforeEach(() => {
|
||||
postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey }));
|
||||
});
|
||||
|
||||
it("allows the user to view a cipher on click", () => {
|
||||
const viewCipherButton =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector(".view-cipher-button");
|
||||
|
||||
viewCipherButton.dispatchEvent(new Event("click"));
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "viewSelectedCipher", inlineMenuCipherId: "1", portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows the user to move keyboard focus to the current cipher element on ArrowLeft", () => {
|
||||
const cipherContainerElement =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector(".cipher-container");
|
||||
const fillCipherButton = cipherContainerElement.querySelector(".fill-cipher-button");
|
||||
const viewCipherButton = cipherContainerElement.querySelector(".view-cipher-button");
|
||||
jest.spyOn(fillCipherButton as HTMLElement, "focus");
|
||||
|
||||
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowLeft" }));
|
||||
|
||||
expect((fillCipherButton as HTMLElement).focus).toBeCalled();
|
||||
});
|
||||
|
||||
it("allows the user to move keyboard to the next cipher element on ArrowDown", () => {
|
||||
const cipherContainerElements =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll(".cipher-container");
|
||||
const viewCipherButton = cipherContainerElements[0].querySelector(".view-cipher-button");
|
||||
const secondFillCipherButton =
|
||||
cipherContainerElements[1].querySelector(".fill-cipher-button");
|
||||
jest.spyOn(secondFillCipherButton as HTMLElement, "focus");
|
||||
|
||||
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowDown" }));
|
||||
|
||||
expect((secondFillCipherButton as HTMLElement).focus).toBeCalled();
|
||||
});
|
||||
|
||||
it("allows the user to move keyboard focus to the previous cipher element on ArrowUp", () => {
|
||||
const cipherContainerElements =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll(".cipher-container");
|
||||
const viewCipherButton = cipherContainerElements[1].querySelector(".view-cipher-button");
|
||||
const firstFillCipherButton =
|
||||
cipherContainerElements[0].querySelector(".fill-cipher-button");
|
||||
jest.spyOn(firstFillCipherButton as HTMLElement, "focus");
|
||||
|
||||
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowUp" }));
|
||||
|
||||
expect((firstFillCipherButton as HTMLElement).focus).toBeCalled();
|
||||
});
|
||||
|
||||
it("ignores keyup events that do not include ArrowUp, ArrowDown, or ArrowRight", () => {
|
||||
const viewCipherButton =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector(".view-cipher-button");
|
||||
jest.spyOn(viewCipherButton as HTMLElement, "focus");
|
||||
|
||||
viewCipherButton.dispatchEvent(new KeyboardEvent("keyup", { code: "ArrowRight" }));
|
||||
|
||||
expect((viewCipherButton as HTMLElement).focus).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("global event listener handlers", () => {
|
||||
beforeEach(() => {
|
||||
postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey }));
|
||||
});
|
||||
|
||||
it("does not post a `checkAutofillInlineMenuButtonFocused` message to the parent if the inline menu is currently focused", () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true);
|
||||
|
||||
postWindowMessage({ command: "checkAutofillInlineMenuListFocused" });
|
||||
|
||||
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not post a `checkAutofillInlineMenuButtonFocused` message if the inline menu list is currently hovered", () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuList["inlineMenuListContainer"], "matches")
|
||||
.mockReturnValue(true);
|
||||
|
||||
postWindowMessage({ command: "checkAutofillInlineMenuListFocused" });
|
||||
|
||||
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts a `checkAutofillInlineMenuButtonFocused` message to the parent if the inline menu is not currently focused", () => {
|
||||
jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false);
|
||||
jest
|
||||
.spyOn(autofillInlineMenuList["inlineMenuListContainer"], "matches")
|
||||
.mockReturnValue(false);
|
||||
|
||||
postWindowMessage({ command: "checkAutofillInlineMenuListFocused" });
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "checkAutofillInlineMenuButtonFocused", portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the list of ciphers", () => {
|
||||
postWindowMessage(createInitAutofillInlineMenuListMessageMock());
|
||||
const updateCiphersSpy = jest.spyOn(autofillInlineMenuList as any, "updateListItems");
|
||||
|
||||
postWindowMessage({ command: "updateAutofillInlineMenuListCiphers" });
|
||||
|
||||
expect(updateCiphersSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("directing user focus into the inline menu list", () => {
|
||||
it("sets ARIA attributes that define the list as a `dialog` to screen reader users", () => {
|
||||
postWindowMessage(
|
||||
createInitAutofillInlineMenuListMessageMock({
|
||||
authStatus: AuthenticationStatus.Locked,
|
||||
cipherList: [],
|
||||
}),
|
||||
);
|
||||
const inlineMenuContainerSetAttributeSpy = jest.spyOn(
|
||||
autofillInlineMenuList["inlineMenuListContainer"],
|
||||
"setAttribute",
|
||||
);
|
||||
|
||||
postWindowMessage({ command: "focusAutofillInlineMenuList" });
|
||||
|
||||
expect(inlineMenuContainerSetAttributeSpy).toHaveBeenCalledWith("role", "dialog");
|
||||
expect(inlineMenuContainerSetAttributeSpy).toHaveBeenCalledWith("aria-modal", "true");
|
||||
});
|
||||
|
||||
it("focuses the unlock button element if the user is not authenticated", async () => {
|
||||
postWindowMessage(
|
||||
createInitAutofillInlineMenuListMessageMock({
|
||||
authStatus: AuthenticationStatus.Locked,
|
||||
cipherList: [],
|
||||
}),
|
||||
);
|
||||
await flushPromises();
|
||||
const unlockButton =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector("#unlock-button");
|
||||
jest.spyOn(unlockButton as HTMLElement, "focus");
|
||||
|
||||
postWindowMessage({ command: "focusAutofillInlineMenuList" });
|
||||
|
||||
expect((unlockButton as HTMLElement).focus).toBeCalled();
|
||||
});
|
||||
|
||||
it("focuses the new item button element if the cipher list is empty", async () => {
|
||||
postWindowMessage(createInitAutofillInlineMenuListMessageMock({ ciphers: [] }));
|
||||
await flushPromises();
|
||||
const newItemButton =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector("#new-item-button");
|
||||
jest.spyOn(newItemButton as HTMLElement, "focus");
|
||||
|
||||
postWindowMessage({ command: "focusAutofillInlineMenuList" });
|
||||
|
||||
expect((newItemButton as HTMLElement).focus).toBeCalled();
|
||||
});
|
||||
|
||||
it("focuses the first cipher button element if the cipher list is populated", () => {
|
||||
postWindowMessage(createInitAutofillInlineMenuListMessageMock());
|
||||
const firstCipherItem =
|
||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector(".fill-cipher-button");
|
||||
jest.spyOn(firstCipherItem as HTMLElement, "focus");
|
||||
|
||||
postWindowMessage({ command: "focusAutofillInlineMenuList" });
|
||||
|
||||
expect((firstCipherItem as HTMLElement).focus).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("blur event", () => {
|
||||
it("posts a message to the parent window indicating that the inline menu has lost focus", () => {
|
||||
postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey }));
|
||||
|
||||
globalThis.dispatchEvent(new Event("blur"));
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "autofillInlineMenuBlurred", portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keydown event", () => {
|
||||
beforeEach(() => {
|
||||
postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey }));
|
||||
});
|
||||
|
||||
it("skips redirecting keyboard focus when a KeyDown event triggers and the key is not a `Tab` or `Escape` key", () => {
|
||||
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "test" }));
|
||||
|
||||
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects the inline menu focus out to the previous element on KeyDown of the `Tab+Shift` keys", () => {
|
||||
globalThis.document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { code: "Tab", shiftKey: true }),
|
||||
);
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "redirectAutofillInlineMenuFocusOut", direction: "previous", portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects the inline menu focus out to the next element on KeyDown of the `Tab` key", () => {
|
||||
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Tab" }));
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "redirectAutofillInlineMenuFocusOut", direction: "next", portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects the inline menu focus out to the current element on KeyDown of the `Escape` key", () => {
|
||||
globalThis.document.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" }));
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "redirectAutofillInlineMenuFocusOut", direction: "current", portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleResizeObserver", () => {
|
||||
beforeEach(() => {
|
||||
postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey }));
|
||||
});
|
||||
|
||||
it("ignores resize entries whose target is not the inline menu list", () => {
|
||||
const entries = [
|
||||
{
|
||||
target: mock<HTMLElement>(),
|
||||
contentRect: { height: 300 },
|
||||
},
|
||||
];
|
||||
|
||||
autofillInlineMenuList["handleResizeObserver"](entries as unknown as ResizeObserverEntry[]);
|
||||
|
||||
expect(globalThis.parent.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts a message to update the inline menu list height if the list container is resized", () => {
|
||||
const entries = [
|
||||
{
|
||||
target: autofillInlineMenuList["inlineMenuListContainer"],
|
||||
contentRect: { height: 300 },
|
||||
},
|
||||
];
|
||||
|
||||
autofillInlineMenuList["handleResizeObserver"](entries as unknown as ResizeObserverEntry[]);
|
||||
|
||||
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
|
||||
{ command: "updateAutofillInlineMenuListHeight", styles: { height: "300px" }, portKey },
|
||||
"*",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,626 @@
|
||||
import "@webcomponents/custom-elements";
|
||||
import "lit/polyfill-support.js";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import { InlineMenuCipherData } from "../../../../background/abstractions/overlay.background";
|
||||
import { buildSvgDomElement } from "../../../../utils";
|
||||
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../../utils/svg-icons";
|
||||
import {
|
||||
InitAutofillInlineMenuListMessage,
|
||||
AutofillInlineMenuListWindowMessageHandlers,
|
||||
} from "../../abstractions/autofill-inline-menu-list";
|
||||
import { AutofillInlineMenuPageElement } from "../shared/autofill-inline-menu-page-element";
|
||||
|
||||
export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
private inlineMenuListContainer: HTMLDivElement;
|
||||
private resizeObserver: ResizeObserver;
|
||||
private eventHandlersMemo: { [key: string]: EventListener } = {};
|
||||
private ciphers: InlineMenuCipherData[] = [];
|
||||
private ciphersList: HTMLUListElement;
|
||||
private cipherListScrollIsDebounced = false;
|
||||
private cipherListScrollDebounceTimeout: number | NodeJS.Timeout;
|
||||
private currentCipherIndex = 0;
|
||||
private readonly showCiphersPerPage = 6;
|
||||
private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers =
|
||||
{
|
||||
initAutofillInlineMenuList: ({ message }) => this.initAutofillInlineMenuList(message),
|
||||
checkAutofillInlineMenuListFocused: () => this.checkInlineMenuListFocused(),
|
||||
updateAutofillInlineMenuListCiphers: ({ message }) => this.updateListItems(message.ciphers),
|
||||
focusAutofillInlineMenuList: () => this.focusInlineMenuList(),
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.setupInlineMenuListGlobalListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the inline menu list and updates the list items with the passed ciphers.
|
||||
* If the auth status is not `Unlocked`, the locked inline menu is built.
|
||||
*
|
||||
* @param translations - The translations to use for the inline menu list.
|
||||
* @param styleSheetUrl - The URL of the stylesheet to use for the inline menu list.
|
||||
* @param theme - The theme to use for the inline menu list.
|
||||
* @param authStatus - The current authentication status.
|
||||
* @param ciphers - The ciphers to display in the inline menu list.
|
||||
* @param portKey - Background generated key that allows the port to communicate with the background.
|
||||
*/
|
||||
private async initAutofillInlineMenuList({
|
||||
translations,
|
||||
styleSheetUrl,
|
||||
theme,
|
||||
authStatus,
|
||||
ciphers,
|
||||
portKey,
|
||||
}: InitAutofillInlineMenuListMessage) {
|
||||
const linkElement = await this.initAutofillInlineMenuPage(
|
||||
"list",
|
||||
styleSheetUrl,
|
||||
translations,
|
||||
portKey,
|
||||
);
|
||||
|
||||
const themeClass = `theme_${theme}`;
|
||||
globalThis.document.documentElement.classList.add(themeClass);
|
||||
|
||||
this.inlineMenuListContainer = globalThis.document.createElement("div");
|
||||
this.inlineMenuListContainer.classList.add("inline-menu-list-container", themeClass);
|
||||
this.resizeObserver.observe(this.inlineMenuListContainer);
|
||||
|
||||
this.shadowDom.append(linkElement, this.inlineMenuListContainer);
|
||||
|
||||
if (authStatus === AuthenticationStatus.Unlocked) {
|
||||
this.updateListItems(ciphers);
|
||||
return;
|
||||
}
|
||||
|
||||
this.buildLockedInlineMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the locked inline menu, which is displayed when the user is not authenticated.
|
||||
* Facilitates the ability to unlock the extension from the inline menu.
|
||||
*/
|
||||
private buildLockedInlineMenu() {
|
||||
const lockedInlineMenu = globalThis.document.createElement("div");
|
||||
lockedInlineMenu.id = "locked-inline-menu-description";
|
||||
lockedInlineMenu.classList.add("locked-inline-menu", "inline-menu-list-message");
|
||||
lockedInlineMenu.textContent = this.getTranslation("unlockYourAccount");
|
||||
|
||||
const unlockButtonElement = globalThis.document.createElement("button");
|
||||
unlockButtonElement.id = "unlock-button";
|
||||
unlockButtonElement.tabIndex = -1;
|
||||
unlockButtonElement.classList.add("unlock-button", "inline-menu-list-button");
|
||||
unlockButtonElement.textContent = this.getTranslation("unlockAccount");
|
||||
unlockButtonElement.setAttribute(
|
||||
"aria-label",
|
||||
`${this.getTranslation("unlockAccount")}, ${this.getTranslation("opensInANewWindow")}`,
|
||||
);
|
||||
unlockButtonElement.prepend(buildSvgDomElement(lockIcon));
|
||||
unlockButtonElement.addEventListener(EVENTS.CLICK, this.handleUnlockButtonClick);
|
||||
|
||||
const inlineMenuListButtonContainer = globalThis.document.createElement("div");
|
||||
inlineMenuListButtonContainer.classList.add("inline-menu-list-button-container");
|
||||
inlineMenuListButtonContainer.appendChild(unlockButtonElement);
|
||||
|
||||
this.inlineMenuListContainer.append(lockedInlineMenu, inlineMenuListButtonContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the click event for the unlock button.
|
||||
* Sends a message to the parent window to unlock the vault.
|
||||
*/
|
||||
private handleUnlockButtonClick = () => {
|
||||
this.postMessageToParent({ command: "unlockVault" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the list items with the passed ciphers.
|
||||
* If no ciphers are passed, the no results inline menu is built.
|
||||
*
|
||||
* @param ciphers - The ciphers to display in the inline menu list.
|
||||
*/
|
||||
private updateListItems(ciphers: InlineMenuCipherData[]) {
|
||||
this.ciphers = ciphers;
|
||||
this.currentCipherIndex = 0;
|
||||
if (this.inlineMenuListContainer) {
|
||||
this.inlineMenuListContainer.innerHTML = "";
|
||||
}
|
||||
|
||||
if (!ciphers?.length) {
|
||||
this.buildNoResultsInlineMenuList();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ciphersList = globalThis.document.createElement("ul");
|
||||
this.ciphersList.classList.add("inline-menu-list-actions");
|
||||
this.ciphersList.setAttribute("role", "list");
|
||||
globalThis.addEventListener(EVENTS.SCROLL, this.handleCiphersListScrollEvent);
|
||||
|
||||
this.loadPageOfCiphers();
|
||||
|
||||
this.inlineMenuListContainer.appendChild(this.ciphersList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline menu view that is presented when no ciphers are found for a given page.
|
||||
* Facilitates the ability to add a new vault item from the inline menu.
|
||||
*/
|
||||
private buildNoResultsInlineMenuList() {
|
||||
const noItemsMessage = globalThis.document.createElement("div");
|
||||
noItemsMessage.classList.add("no-items", "inline-menu-list-message");
|
||||
noItemsMessage.textContent = this.getTranslation("noItemsToShow");
|
||||
|
||||
const newItemButton = globalThis.document.createElement("button");
|
||||
newItemButton.tabIndex = -1;
|
||||
newItemButton.id = "new-item-button";
|
||||
newItemButton.classList.add("add-new-item-button", "inline-menu-list-button");
|
||||
newItemButton.textContent = this.getTranslation("newItem");
|
||||
newItemButton.setAttribute(
|
||||
"aria-label",
|
||||
`${this.getTranslation("addNewVaultItem")}, ${this.getTranslation("opensInANewWindow")}`,
|
||||
);
|
||||
newItemButton.prepend(buildSvgDomElement(plusIcon));
|
||||
newItemButton.addEventListener(EVENTS.CLICK, this.handeNewItemButtonClick);
|
||||
|
||||
const inlineMenuListButtonContainer = globalThis.document.createElement("div");
|
||||
inlineMenuListButtonContainer.classList.add("inline-menu-list-button-container");
|
||||
inlineMenuListButtonContainer.appendChild(newItemButton);
|
||||
|
||||
this.inlineMenuListContainer.append(noItemsMessage, inlineMenuListButtonContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the click event for the new item button.
|
||||
* Sends a message to the parent window to add a new vault item.
|
||||
*/
|
||||
private handeNewItemButtonClick = () => {
|
||||
this.postMessageToParent({ command: "addNewVaultItem" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads a page of ciphers into the inline menu list container.
|
||||
*/
|
||||
private loadPageOfCiphers() {
|
||||
const lastIndex = Math.min(
|
||||
this.currentCipherIndex + this.showCiphersPerPage,
|
||||
this.ciphers.length,
|
||||
);
|
||||
for (let cipherIndex = this.currentCipherIndex; cipherIndex < lastIndex; cipherIndex++) {
|
||||
this.ciphersList.appendChild(this.buildInlineMenuListActionsItem(this.ciphers[cipherIndex]));
|
||||
this.currentCipherIndex++;
|
||||
}
|
||||
|
||||
if (this.currentCipherIndex >= this.ciphers.length) {
|
||||
globalThis.removeEventListener(EVENTS.SCROLL, this.handleCiphersListScrollEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles updating the list of ciphers when the
|
||||
* user scrolls to the bottom of the list.
|
||||
*/
|
||||
private handleCiphersListScrollEvent = () => {
|
||||
if (this.cipherListScrollIsDebounced) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cipherListScrollIsDebounced = true;
|
||||
if (this.cipherListScrollDebounceTimeout) {
|
||||
clearTimeout(this.cipherListScrollDebounceTimeout);
|
||||
}
|
||||
this.cipherListScrollDebounceTimeout = globalThis.setTimeout(
|
||||
this.handleDebouncedScrollEvent,
|
||||
300,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Debounced handler for updating the list of ciphers when the user scrolls to
|
||||
* the bottom of the list. Triggers at most once every 300ms.
|
||||
*/
|
||||
private handleDebouncedScrollEvent = () => {
|
||||
this.cipherListScrollIsDebounced = false;
|
||||
|
||||
if (globalThis.scrollY + globalThis.innerHeight >= this.ciphersList.clientHeight - 300) {
|
||||
this.loadPageOfCiphers();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the list item for a given cipher.
|
||||
*
|
||||
* @param cipher - The cipher to build the list item for.
|
||||
*/
|
||||
private buildInlineMenuListActionsItem(cipher: InlineMenuCipherData) {
|
||||
const fillCipherElement = this.buildFillCipherElement(cipher);
|
||||
const viewCipherElement = this.buildViewCipherElement(cipher);
|
||||
|
||||
const cipherContainerElement = globalThis.document.createElement("div");
|
||||
cipherContainerElement.classList.add("cipher-container");
|
||||
cipherContainerElement.append(fillCipherElement, viewCipherElement);
|
||||
|
||||
const inlineMenuListActionsItem = globalThis.document.createElement("li");
|
||||
inlineMenuListActionsItem.setAttribute("role", "listitem");
|
||||
inlineMenuListActionsItem.classList.add("inline-menu-list-actions-item");
|
||||
inlineMenuListActionsItem.appendChild(cipherContainerElement);
|
||||
|
||||
return inlineMenuListActionsItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the fill cipher button for a given cipher.
|
||||
* Wraps the cipher icon and details.
|
||||
*
|
||||
* @param cipher - The cipher to build the fill cipher button for.
|
||||
*/
|
||||
private buildFillCipherElement(cipher: InlineMenuCipherData) {
|
||||
const cipherIcon = this.buildCipherIconElement(cipher);
|
||||
const cipherDetailsElement = this.buildCipherDetailsElement(cipher);
|
||||
|
||||
const fillCipherElement = globalThis.document.createElement("button");
|
||||
fillCipherElement.tabIndex = -1;
|
||||
fillCipherElement.classList.add("fill-cipher-button");
|
||||
fillCipherElement.setAttribute(
|
||||
"aria-label",
|
||||
`${this.getTranslation("fillCredentialsFor")} ${cipher.name}`,
|
||||
);
|
||||
fillCipherElement.setAttribute(
|
||||
"aria-description",
|
||||
`${this.getTranslation("username")}, ${cipher.login.username}`,
|
||||
);
|
||||
fillCipherElement.append(cipherIcon, cipherDetailsElement);
|
||||
fillCipherElement.addEventListener(EVENTS.CLICK, this.handleFillCipherClickEvent(cipher));
|
||||
fillCipherElement.addEventListener(EVENTS.KEYUP, this.handleFillCipherKeyUpEvent);
|
||||
|
||||
return fillCipherElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the click event for the fill cipher button.
|
||||
* Sends a message to the parent window to fill the selected cipher.
|
||||
*
|
||||
* @param cipher - The cipher to fill.
|
||||
*/
|
||||
private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => {
|
||||
return this.useEventHandlersMemo(
|
||||
() =>
|
||||
this.postMessageToParent({
|
||||
command: "fillAutofillInlineMenuCipher",
|
||||
inlineMenuCipherId: cipher.id,
|
||||
}),
|
||||
`${cipher.id}-fill-cipher-button-click-handler`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the keyup event for the fill cipher button. Facilitates
|
||||
* selecting the next/previous cipher item on ArrowDown/ArrowUp. Also
|
||||
* facilitates moving keyboard focus to the view cipher button on ArrowRight.
|
||||
*
|
||||
* @param event - The keyup event.
|
||||
*/
|
||||
private handleFillCipherKeyUpEvent = (event: KeyboardEvent) => {
|
||||
const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowRight"]);
|
||||
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const currentListItem = event.target.closest(".inline-menu-list-actions-item") as HTMLElement;
|
||||
if (event.code === "ArrowDown") {
|
||||
this.focusNextListItem(currentListItem);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === "ArrowUp") {
|
||||
this.focusPreviousListItem(currentListItem);
|
||||
return;
|
||||
}
|
||||
|
||||
this.focusViewCipherButton(currentListItem, event.target as HTMLElement);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the button that facilitates viewing a cipher in the vault.
|
||||
*
|
||||
* @param cipher - The cipher to view.
|
||||
*/
|
||||
private buildViewCipherElement(cipher: InlineMenuCipherData) {
|
||||
const viewCipherElement = globalThis.document.createElement("button");
|
||||
viewCipherElement.tabIndex = -1;
|
||||
viewCipherElement.classList.add("view-cipher-button");
|
||||
viewCipherElement.setAttribute(
|
||||
"aria-label",
|
||||
`${this.getTranslation("view")} ${cipher.name}, ${this.getTranslation("opensInANewWindow")}`,
|
||||
);
|
||||
viewCipherElement.append(buildSvgDomElement(viewCipherIcon));
|
||||
viewCipherElement.addEventListener(EVENTS.CLICK, this.handleViewCipherClickEvent(cipher));
|
||||
viewCipherElement.addEventListener(EVENTS.KEYUP, this.handleViewCipherKeyUpEvent);
|
||||
|
||||
return viewCipherElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the click event for the view cipher button. Sends a
|
||||
* message to the parent window to view the selected cipher.
|
||||
*
|
||||
* @param cipher - The cipher to view.
|
||||
*/
|
||||
private handleViewCipherClickEvent = (cipher: InlineMenuCipherData) => {
|
||||
return this.useEventHandlersMemo(
|
||||
() =>
|
||||
this.postMessageToParent({ command: "viewSelectedCipher", inlineMenuCipherId: cipher.id }),
|
||||
`${cipher.id}-view-cipher-button-click-handler`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the keyup event for the view cipher button. Facilitates
|
||||
* selecting the next/previous cipher item on ArrowDown/ArrowUp.
|
||||
* Also facilitates moving keyboard focus to the current fill
|
||||
* cipher button on ArrowLeft.
|
||||
*
|
||||
* @param event - The keyup event.
|
||||
*/
|
||||
private handleViewCipherKeyUpEvent = (event: KeyboardEvent) => {
|
||||
const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowLeft"]);
|
||||
if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const currentListItem = event.target.closest(".inline-menu-list-actions-item") as HTMLElement;
|
||||
const cipherContainer = currentListItem.querySelector(".cipher-container") as HTMLElement;
|
||||
cipherContainer?.classList.remove("remove-outline");
|
||||
if (event.code === "ArrowDown") {
|
||||
this.focusNextListItem(currentListItem);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === "ArrowUp") {
|
||||
this.focusPreviousListItem(currentListItem);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSibling = event.target.previousElementSibling as HTMLElement;
|
||||
previousSibling?.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the icon for a given cipher. Prioritizes the favicon from a given cipher url
|
||||
* and the default icon element within the extension. If neither are available, the
|
||||
* globe icon is used.
|
||||
*
|
||||
* @param cipher - The cipher to build the icon for.
|
||||
*/
|
||||
private buildCipherIconElement(cipher: InlineMenuCipherData) {
|
||||
const cipherIcon = globalThis.document.createElement("span");
|
||||
cipherIcon.classList.add("cipher-icon");
|
||||
cipherIcon.setAttribute("aria-hidden", "true");
|
||||
|
||||
if (cipher.icon?.image) {
|
||||
try {
|
||||
const url = new URL(cipher.icon.image);
|
||||
cipherIcon.style.backgroundImage = `url(${url.href})`;
|
||||
|
||||
const dummyImageElement = globalThis.document.createElement("img");
|
||||
dummyImageElement.src = url.href;
|
||||
dummyImageElement.addEventListener("error", () => {
|
||||
cipherIcon.style.backgroundImage = "";
|
||||
cipherIcon.classList.add("cipher-icon", "bwi", cipher.icon.icon);
|
||||
});
|
||||
dummyImageElement.remove();
|
||||
|
||||
return cipherIcon;
|
||||
} catch {
|
||||
// Silently default to the globe icon element if the image URL is invalid
|
||||
}
|
||||
}
|
||||
|
||||
if (cipher.icon?.icon) {
|
||||
cipherIcon.classList.add("cipher-icon", "bwi", cipher.icon.icon);
|
||||
return cipherIcon;
|
||||
}
|
||||
|
||||
cipherIcon.append(buildSvgDomElement(globeIcon));
|
||||
return cipherIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the details for a given cipher. Includes the cipher name and username login.
|
||||
*
|
||||
* @param cipher - The cipher to build the details for.
|
||||
*/
|
||||
private buildCipherDetailsElement(cipher: InlineMenuCipherData) {
|
||||
const cipherNameElement = this.buildCipherNameElement(cipher);
|
||||
const cipherUserLoginElement = this.buildCipherUserLoginElement(cipher);
|
||||
|
||||
const cipherDetailsElement = globalThis.document.createElement("span");
|
||||
cipherDetailsElement.classList.add("cipher-details");
|
||||
if (cipherNameElement) {
|
||||
cipherDetailsElement.appendChild(cipherNameElement);
|
||||
}
|
||||
if (cipherUserLoginElement) {
|
||||
cipherDetailsElement.appendChild(cipherUserLoginElement);
|
||||
}
|
||||
|
||||
return cipherDetailsElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the name element for a given cipher.
|
||||
*
|
||||
* @param cipher - The cipher to build the name element for.
|
||||
*/
|
||||
private buildCipherNameElement(cipher: InlineMenuCipherData): HTMLSpanElement | null {
|
||||
if (!cipher.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cipherNameElement = globalThis.document.createElement("span");
|
||||
cipherNameElement.classList.add("cipher-name");
|
||||
cipherNameElement.textContent = cipher.name;
|
||||
cipherNameElement.setAttribute("title", cipher.name);
|
||||
|
||||
return cipherNameElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the username login element for a given cipher.
|
||||
*
|
||||
* @param cipher - The cipher to build the username login element for.
|
||||
*/
|
||||
private buildCipherUserLoginElement(cipher: InlineMenuCipherData): HTMLSpanElement | null {
|
||||
if (!cipher.login?.username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cipherUserLoginElement = globalThis.document.createElement("span");
|
||||
cipherUserLoginElement.classList.add("cipher-user-login");
|
||||
cipherUserLoginElement.textContent = cipher.login.username;
|
||||
cipherUserLoginElement.setAttribute("title", cipher.login.username);
|
||||
|
||||
return cipherUserLoginElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates whether the inline menu list iframe is currently focused.
|
||||
* If not focused, will check if the button element is focused.
|
||||
*/
|
||||
private checkInlineMenuListFocused() {
|
||||
if (globalThis.document.hasFocus() || this.inlineMenuListContainer.matches(":hover")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.postMessageToParent({ command: "checkAutofillInlineMenuButtonFocused" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the inline menu list iframe. The element that receives focus is
|
||||
* determined by the presence of the unlock button, new item button, or
|
||||
* the first cipher button.
|
||||
*/
|
||||
private focusInlineMenuList() {
|
||||
this.inlineMenuListContainer.setAttribute("role", "dialog");
|
||||
this.inlineMenuListContainer.setAttribute("aria-modal", "true");
|
||||
|
||||
const unlockButtonElement = this.inlineMenuListContainer.querySelector(
|
||||
"#unlock-button",
|
||||
) as HTMLElement;
|
||||
if (unlockButtonElement) {
|
||||
unlockButtonElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const newItemButtonElement = this.inlineMenuListContainer.querySelector(
|
||||
"#new-item-button",
|
||||
) as HTMLElement;
|
||||
if (newItemButtonElement) {
|
||||
newItemButtonElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstCipherElement = this.inlineMenuListContainer.querySelector(
|
||||
".fill-cipher-button",
|
||||
) as HTMLElement;
|
||||
firstCipherElement?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the global listeners for the inline menu list iframe.
|
||||
*/
|
||||
private setupInlineMenuListGlobalListeners() {
|
||||
this.setupGlobalListeners(this.inlineMenuListWindowMessageHandlers);
|
||||
|
||||
this.resizeObserver = new ResizeObserver(this.handleResizeObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the resize observer event. Facilitates updating the height of the
|
||||
* inline menu list iframe when the height of the list changes.
|
||||
*
|
||||
* @param entries - The resize observer entries.
|
||||
*/
|
||||
private handleResizeObserver = (entries: ResizeObserverEntry[]) => {
|
||||
for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
|
||||
const entry = entries[entryIndex];
|
||||
if (entry.target !== this.inlineMenuListContainer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { height } = entry.contentRect;
|
||||
this.postMessageToParent({
|
||||
command: "updateAutofillInlineMenuListHeight",
|
||||
styles: { height: `${height}px` },
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Establishes a memoized event handler for a given event.
|
||||
*
|
||||
* @param eventHandler - The event handler to memoize.
|
||||
* @param memoIndex - The memo index to use for the event handler.
|
||||
*/
|
||||
private useEventHandlersMemo = (eventHandler: EventListener, memoIndex: string) => {
|
||||
return this.eventHandlersMemo[memoIndex] || (this.eventHandlersMemo[memoIndex] = eventHandler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Focuses the next list item in the inline menu list. If the current list item is the last
|
||||
* item in the list, the first item is focused.
|
||||
*
|
||||
* @param currentListItem - The current list item.
|
||||
*/
|
||||
private focusNextListItem(currentListItem: HTMLElement) {
|
||||
const nextListItem = currentListItem.nextSibling as HTMLElement;
|
||||
const nextSibling = nextListItem?.querySelector(".fill-cipher-button") as HTMLElement;
|
||||
if (nextSibling) {
|
||||
nextSibling.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstListItem = currentListItem.parentElement?.firstChild as HTMLElement;
|
||||
const firstSibling = firstListItem?.querySelector(".fill-cipher-button") as HTMLElement;
|
||||
firstSibling?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the previous list item in the inline menu list. If the current list item is the first
|
||||
* item in the list, the last item is focused.
|
||||
*
|
||||
* @param currentListItem - The current list item.
|
||||
*/
|
||||
private focusPreviousListItem(currentListItem: HTMLElement) {
|
||||
const previousListItem = currentListItem.previousSibling as HTMLElement;
|
||||
const previousSibling = previousListItem?.querySelector(".fill-cipher-button") as HTMLElement;
|
||||
if (previousSibling) {
|
||||
previousSibling.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const lastListItem = currentListItem.parentElement?.lastChild as HTMLElement;
|
||||
const lastSibling = lastListItem?.querySelector(".fill-cipher-button") as HTMLElement;
|
||||
lastSibling?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the view cipher button relative to the current fill cipher button.
|
||||
*
|
||||
* @param currentListItem - The current list item.
|
||||
* @param currentButtonElement - The current button element.
|
||||
*/
|
||||
private focusViewCipherButton(currentListItem: HTMLElement, currentButtonElement: HTMLElement) {
|
||||
const cipherContainer = currentListItem.querySelector(".cipher-container") as HTMLElement;
|
||||
cipherContainer.classList.add("remove-outline");
|
||||
|
||||
const nextSibling = currentButtonElement.nextElementSibling as HTMLElement;
|
||||
nextSibling?.focus();
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum";
|
||||
|
||||
import { AutofillInlineMenuList } from "./autofill-inline-menu-list";
|
||||
|
||||
require("./list.scss");
|
||||
|
||||
(function () {
|
||||
globalThis.customElements.define(AutofillOverlayElement.List, AutofillInlineMenuList);
|
||||
})();
|
@ -7,6 +7,6 @@
|
||||
<meta name="color-scheme" content="normal" />
|
||||
</head>
|
||||
<body>
|
||||
<autofill-overlay-list></autofill-overlay-list>
|
||||
<autofill-inline-menu-list></autofill-inline-menu-list>
|
||||
</body>
|
||||
</html>
|
@ -1,7 +1,7 @@
|
||||
@import "../../../../../../../libs/angular/src/scss/webfonts.css";
|
||||
@import "../../../../../../../libs/angular/src/scss/bwicons/styles/style";
|
||||
@import "../../../shared/styles/variables";
|
||||
@import "../../../../../../../libs/angular/src/scss/icons";
|
||||
@import "../../../../../../../../libs/angular/src/scss/webfonts.css";
|
||||
@import "../../../../../../../../libs/angular/src/scss/bwicons/styles/style";
|
||||
@import "../../../../shared/styles/variables";
|
||||
@import "../../../../../../../../libs/angular/src/scss/icons";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@ -22,7 +22,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-list-message {
|
||||
.inline-menu-list-message {
|
||||
font-family: $font-family-sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1.4rem;
|
||||
@ -39,7 +39,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-list-button-container {
|
||||
.inline-menu-list-button-container {
|
||||
width: 100%;
|
||||
padding: 0.2rem;
|
||||
background: transparent;
|
||||
@ -58,7 +58,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-list-button {
|
||||
.inline-menu-list-button {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
justify-content: flex-start;
|
||||
@ -116,12 +116,12 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-actions-list {
|
||||
.inline-menu-list-actions {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.overlay-actions-list-item {
|
||||
.inline-menu-list-actions-item {
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
list-style: none;
|
||||
padding: 0.2rem;
|
@ -0,0 +1,130 @@
|
||||
import { AutofillOverlayPort } from "../../../../enums/autofill-overlay.enum";
|
||||
import { createPortSpyMock } from "../../../../spec/autofill-mocks";
|
||||
import { postWindowMessage } from "../../../../spec/testing-utils";
|
||||
|
||||
import { AutofillInlineMenuContainer } from "./autofill-inline-menu-container";
|
||||
|
||||
describe("AutofillInlineMenuContainer", () => {
|
||||
const portKey = "testPortKey";
|
||||
const iframeUrl = "https://example.com";
|
||||
const pageTitle = "Example";
|
||||
let autofillInlineMenuContainer: AutofillInlineMenuContainer;
|
||||
|
||||
beforeEach(() => {
|
||||
autofillInlineMenuContainer = new AutofillInlineMenuContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("initializing the inline menu iframe", () => {
|
||||
it("sets the default iframe attributes to the message values", () => {
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuList",
|
||||
iframeUrl,
|
||||
pageTitle,
|
||||
portKey,
|
||||
portName: AutofillOverlayPort.List,
|
||||
};
|
||||
|
||||
postWindowMessage(message);
|
||||
|
||||
expect(autofillInlineMenuContainer["defaultIframeAttributes"].src).toBe(message.iframeUrl);
|
||||
expect(autofillInlineMenuContainer["defaultIframeAttributes"].title).toBe(message.pageTitle);
|
||||
expect(autofillInlineMenuContainer["portName"]).toBe(message.portName);
|
||||
});
|
||||
|
||||
it("sets up a onLoad listener on the iframe that sets up the background port message listener", async () => {
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuButton",
|
||||
iframeUrl,
|
||||
pageTitle,
|
||||
portKey,
|
||||
portName: AutofillOverlayPort.Button,
|
||||
};
|
||||
|
||||
postWindowMessage(message);
|
||||
|
||||
jest.spyOn(autofillInlineMenuContainer["inlineMenuPageIframe"].contentWindow, "postMessage");
|
||||
autofillInlineMenuContainer["inlineMenuPageIframe"].dispatchEvent(new Event("load"));
|
||||
|
||||
expect(chrome.runtime.connect).toHaveBeenCalledWith({ name: message.portName });
|
||||
expect(
|
||||
autofillInlineMenuContainer["inlineMenuPageIframe"].contentWindow.postMessage,
|
||||
).toHaveBeenCalledWith(message, "*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handling window messages", () => {
|
||||
let iframe: HTMLIFrameElement;
|
||||
let port: chrome.runtime.Port;
|
||||
|
||||
beforeEach(() => {
|
||||
const message = {
|
||||
command: "initAutofillInlineMenuButton",
|
||||
iframeUrl,
|
||||
pageTitle,
|
||||
portKey,
|
||||
portName: AutofillOverlayPort.Button,
|
||||
};
|
||||
|
||||
postWindowMessage(message);
|
||||
|
||||
iframe = autofillInlineMenuContainer["inlineMenuPageIframe"];
|
||||
jest.spyOn(iframe.contentWindow, "postMessage");
|
||||
port = createPortSpyMock(AutofillOverlayPort.Button);
|
||||
autofillInlineMenuContainer["port"] = port;
|
||||
});
|
||||
|
||||
it("ignores messages that do not contain a portKey", () => {
|
||||
const message = { command: "checkInlineMenuButtonFocused" };
|
||||
|
||||
postWindowMessage(message, "*", iframe.contentWindow as any);
|
||||
|
||||
expect(port.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores messages if the inline menu iframe has not been created", () => {
|
||||
autofillInlineMenuContainer["inlineMenuPageIframe"] = null;
|
||||
const message = { command: "checkInlineMenuButtonFocused", portKey };
|
||||
|
||||
postWindowMessage(message, "*", iframe.contentWindow as any);
|
||||
|
||||
expect(port.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores messages that do not come from either the parent frame or the inline menu iframe", () => {
|
||||
const randomIframe = document.createElement("iframe");
|
||||
const message = { command: "checkInlineMenuButtonFocused", portKey };
|
||||
|
||||
postWindowMessage(message, "*", randomIframe.contentWindow as any);
|
||||
|
||||
expect(port.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores messages that come from an invalid origin", () => {
|
||||
const message = { command: "checkInlineMenuButtonFocused", portKey };
|
||||
|
||||
postWindowMessage(message, "https://example.com", iframe.contentWindow as any);
|
||||
|
||||
expect(port.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts a message to the background from the inline menu iframe", () => {
|
||||
const message = { command: "checkInlineMenuButtonFocused", portKey };
|
||||
|
||||
postWindowMessage(message, "null", iframe.contentWindow as any);
|
||||
|
||||
expect(port.postMessage).toHaveBeenCalledWith(message);
|
||||
});
|
||||
|
||||
it("posts a message to the inline menu iframe from the parent", () => {
|
||||
const message = { command: "checkInlineMenuButtonFocused", portKey };
|
||||
|
||||
postWindowMessage(message);
|
||||
|
||||
expect(iframe.contentWindow.postMessage).toHaveBeenCalledWith(message, "*");
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,179 @@
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import { setElementStyles } from "../../../../utils";
|
||||
import {
|
||||
InitAutofillInlineMenuElementMessage,
|
||||
AutofillInlineMenuContainerWindowMessageHandlers,
|
||||
AutofillInlineMenuContainerWindowMessage,
|
||||
AutofillInlineMenuContainerPortMessage,
|
||||
} from "../../abstractions/autofill-inline-menu-container";
|
||||
|
||||
export class AutofillInlineMenuContainer {
|
||||
private readonly setElementStyles = setElementStyles;
|
||||
private readonly extensionOriginsSet: Set<string>;
|
||||
private port: chrome.runtime.Port | null = null;
|
||||
private portName: string;
|
||||
private inlineMenuPageIframe: HTMLIFrameElement;
|
||||
private readonly iframeStyles: Partial<CSSStyleDeclaration> = {
|
||||
all: "initial",
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "block",
|
||||
zIndex: "2147483647",
|
||||
lineHeight: "0",
|
||||
overflow: "hidden",
|
||||
visibility: "visible",
|
||||
clipPath: "none",
|
||||
pointerEvents: "auto",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
colorScheme: "normal",
|
||||
};
|
||||
private readonly defaultIframeAttributes: Record<string, string> = {
|
||||
src: "",
|
||||
title: "",
|
||||
sandbox: "allow-scripts",
|
||||
allowtransparency: "true",
|
||||
tabIndex: "-1",
|
||||
};
|
||||
private readonly windowMessageHandlers: AutofillInlineMenuContainerWindowMessageHandlers = {
|
||||
initAutofillInlineMenuButton: (message) => this.handleInitInlineMenuIframe(message),
|
||||
initAutofillInlineMenuList: (message) => this.handleInitInlineMenuIframe(message),
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.extensionOriginsSet = new Set([
|
||||
chrome.runtime.getURL("").slice(0, -1).toLowerCase(), // Remove the trailing slash and normalize the extension url to lowercase
|
||||
"null",
|
||||
]);
|
||||
|
||||
globalThis.addEventListener("message", this.handleWindowMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles initialization of the iframe used to display the inline menu.
|
||||
*
|
||||
* @param message - The message containing the iframe url and page title.
|
||||
*/
|
||||
private handleInitInlineMenuIframe(message: InitAutofillInlineMenuElementMessage) {
|
||||
this.defaultIframeAttributes.src = message.iframeUrl;
|
||||
this.defaultIframeAttributes.title = message.pageTitle;
|
||||
this.portName = message.portName;
|
||||
|
||||
this.inlineMenuPageIframe = globalThis.document.createElement("iframe");
|
||||
this.setElementStyles(this.inlineMenuPageIframe, this.iframeStyles, true);
|
||||
for (const [attribute, value] of Object.entries(this.defaultIframeAttributes)) {
|
||||
this.inlineMenuPageIframe.setAttribute(attribute, value);
|
||||
}
|
||||
const handleInlineMenuPageIframeLoad = () => {
|
||||
this.inlineMenuPageIframe.removeEventListener(EVENTS.LOAD, handleInlineMenuPageIframeLoad);
|
||||
this.setupPortMessageListener(message);
|
||||
};
|
||||
this.inlineMenuPageIframe.addEventListener(EVENTS.LOAD, handleInlineMenuPageIframeLoad);
|
||||
|
||||
globalThis.document.body.appendChild(this.inlineMenuPageIframe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the port message listener for the inline menu page.
|
||||
*
|
||||
* @param message - The message containing the port name.
|
||||
*/
|
||||
private setupPortMessageListener = (message: InitAutofillInlineMenuElementMessage) => {
|
||||
this.port = chrome.runtime.connect({ name: this.portName });
|
||||
this.postMessageToInlineMenuPage(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Posts a message to the inline menu page iframe.
|
||||
*
|
||||
* @param message - The message to post.
|
||||
*/
|
||||
private postMessageToInlineMenuPage(message: AutofillInlineMenuContainerWindowMessage) {
|
||||
if (this.inlineMenuPageIframe?.contentWindow) {
|
||||
this.inlineMenuPageIframe.contentWindow.postMessage(message, "*");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a message from the inline menu iframe to the background script.
|
||||
*
|
||||
* @param message - The message to post.
|
||||
*/
|
||||
private postMessageToBackground(message: AutofillInlineMenuContainerPortMessage) {
|
||||
if (this.port) {
|
||||
this.port.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles window messages, routing them to the appropriate handler.
|
||||
*
|
||||
* @param event - The message event.
|
||||
*/
|
||||
private handleWindowMessage = (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (this.isForeignWindowMessage(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.windowMessageHandlers[message.command]) {
|
||||
this.windowMessageHandlers[message.command](message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isMessageFromParentWindow(event)) {
|
||||
this.postMessageToInlineMenuPage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.postMessageToBackground(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Identifies if the message is from a foreign window. A foreign window message is
|
||||
* considered as any message that does not have a portKey, is not from the parent window,
|
||||
* or is not from the inline menu page iframe.
|
||||
*
|
||||
* @param event - The message event.
|
||||
*/
|
||||
private isForeignWindowMessage(event: MessageEvent) {
|
||||
if (!event.data.portKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isMessageFromParentWindow(event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !this.isMessageFromInlineMenuPageIframe(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies if the message is from the parent window.
|
||||
*
|
||||
* @param event - The message event.
|
||||
*/
|
||||
private isMessageFromParentWindow(event: MessageEvent): boolean {
|
||||
return globalThis.parent === event.source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies if the message is from the inline menu page iframe.
|
||||
*
|
||||
* @param event - The message event.
|
||||
*/
|
||||
private isMessageFromInlineMenuPageIframe(event: MessageEvent): boolean {
|
||||
if (!this.inlineMenuPageIframe) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.inlineMenuPageIframe.contentWindow === event.source &&
|
||||
this.extensionOriginsSet.has(event.origin.toLowerCase())
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
import { AutofillInlineMenuContainer } from "./autofill-inline-menu-container";
|
||||
|
||||
(() => new AutofillInlineMenuContainer())();
|
@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Bitwarden inline menu</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="normal" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
@ -0,0 +1,155 @@
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import { RedirectFocusDirection } from "../../../../enums/autofill-overlay.enum";
|
||||
import {
|
||||
AutofillInlineMenuPageElementWindowMessage,
|
||||
AutofillInlineMenuPageElementWindowMessageHandlers,
|
||||
} from "../../abstractions/autofill-inline-menu-page-element";
|
||||
|
||||
export class AutofillInlineMenuPageElement extends HTMLElement {
|
||||
protected shadowDom: ShadowRoot;
|
||||
protected messageOrigin: string;
|
||||
protected translations: Record<string, string>;
|
||||
private portKey: string;
|
||||
protected windowMessageHandlers: AutofillInlineMenuPageElementWindowMessageHandlers;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.shadowDom = this.attachShadow({ mode: "closed" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the inline menu page element. Facilitates ensuring that the page
|
||||
* is set up with the expected styles and translations.
|
||||
*
|
||||
* @param elementName - The name of the element, e.g. "button" or "list"
|
||||
* @param styleSheetUrl - The URL of the stylesheet to apply to the page
|
||||
* @param translations - The translations to apply to the page
|
||||
* @param portKey - Background generated key that allows the port to communicate with the background
|
||||
*/
|
||||
protected async initAutofillInlineMenuPage(
|
||||
elementName: "button" | "list",
|
||||
styleSheetUrl: string,
|
||||
translations: Record<string, string>,
|
||||
portKey: string,
|
||||
): Promise<HTMLLinkElement> {
|
||||
this.portKey = portKey;
|
||||
|
||||
this.translations = translations;
|
||||
globalThis.document.documentElement.setAttribute("lang", this.getTranslation("locale"));
|
||||
globalThis.document.head.title = this.getTranslation(`${elementName}PageTitle`);
|
||||
|
||||
this.shadowDom.innerHTML = "";
|
||||
const linkElement = globalThis.document.createElement("link");
|
||||
linkElement.setAttribute("rel", "stylesheet");
|
||||
linkElement.setAttribute("href", styleSheetUrl);
|
||||
|
||||
return linkElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a window message to the parent window.
|
||||
*
|
||||
* @param message - The message to post
|
||||
*/
|
||||
protected postMessageToParent(message: AutofillInlineMenuPageElementWindowMessage) {
|
||||
globalThis.parent.postMessage({ portKey: this.portKey, ...message }, "*");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a translation from the translations object.
|
||||
*
|
||||
* @param key - The key of the translation to get
|
||||
*/
|
||||
protected getTranslation(key: string): string {
|
||||
return this.translations[key] || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up global listeners for the window message, window blur, and
|
||||
* document keydown events.
|
||||
*
|
||||
* @param windowMessageHandlers - The window message handlers to use
|
||||
*/
|
||||
protected setupGlobalListeners(
|
||||
windowMessageHandlers: AutofillInlineMenuPageElementWindowMessageHandlers,
|
||||
) {
|
||||
this.windowMessageHandlers = windowMessageHandlers;
|
||||
|
||||
globalThis.addEventListener(EVENTS.MESSAGE, this.handleWindowMessage);
|
||||
globalThis.addEventListener(EVENTS.BLUR, this.handleWindowBlurEvent);
|
||||
globalThis.document.addEventListener(EVENTS.KEYDOWN, this.handleDocumentKeyDownEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles window messages from the parent window.
|
||||
*
|
||||
* @param event - The window message event
|
||||
*/
|
||||
private handleWindowMessage = (event: MessageEvent) => {
|
||||
if (!this.windowMessageHandlers) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.messageOrigin) {
|
||||
this.messageOrigin = event.origin;
|
||||
}
|
||||
|
||||
if (event.origin !== this.messageOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = event?.data;
|
||||
const handler = this.windowMessageHandlers[message?.command];
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler({ message });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the window blur event.
|
||||
*/
|
||||
private handleWindowBlurEvent = () => {
|
||||
this.postMessageToParent({ command: "autofillInlineMenuBlurred" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the document keydown event. Facilitates redirecting the
|
||||
* user focus in the right direction out of the inline menu. Also facilitates
|
||||
* closing the inline menu when the user presses the Escape key.
|
||||
*
|
||||
* @param event - The document keydown event
|
||||
*/
|
||||
private handleDocumentKeyDownEvent = (event: KeyboardEvent) => {
|
||||
const listenedForKeys = new Set(["Tab", "Escape"]);
|
||||
if (!listenedForKeys.has(event.code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.code === "Tab") {
|
||||
this.sendRedirectFocusOutMessage(
|
||||
event.shiftKey ? RedirectFocusDirection.Previous : RedirectFocusDirection.Next,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendRedirectFocusOutMessage(RedirectFocusDirection.Current);
|
||||
};
|
||||
|
||||
/**
|
||||
* Redirects the inline menu focus out to the previous element on KeyDown of the `Tab+Shift` keys.
|
||||
* Redirects the inline menu focus out to the next element on KeyDown of the `Tab` key.
|
||||
* Redirects the inline menu focus out to the current element on KeyDown of the `Escape` key.
|
||||
*
|
||||
* @param direction - The direction to redirect the focus out
|
||||
*/
|
||||
private sendRedirectFocusOutMessage(direction: string) {
|
||||
this.postMessageToParent({ command: "redirectAutofillInlineMenuFocusOut", direction });
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { AutofillOverlayElement } from "../../../utils/autofill-overlay.enum";
|
||||
|
||||
import AutofillOverlayButton from "./autofill-overlay-button";
|
||||
|
||||
require("./button.scss");
|
||||
|
||||
(function () {
|
||||
globalThis.customElements.define(AutofillOverlayElement.Button, AutofillOverlayButton);
|
||||
})();
|
@ -1,9 +0,0 @@
|
||||
import { AutofillOverlayElement } from "../../../utils/autofill-overlay.enum";
|
||||
|
||||
import AutofillOverlayList from "./autofill-overlay-list";
|
||||
|
||||
require("./list.scss");
|
||||
|
||||
(function () {
|
||||
globalThis.customElements.define(AutofillOverlayElement.List, AutofillOverlayList);
|
||||
})();
|
@ -1,36 +1,48 @@
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
import { SubFrameOffsetData } from "../../background/abstractions/overlay.background";
|
||||
import { AutofillExtensionMessageParam } from "../../content/abstractions/autofill-init";
|
||||
import AutofillField from "../../models/autofill-field";
|
||||
import AutofillPageDetails from "../../models/autofill-page-details";
|
||||
import { ElementWithOpId, FormFieldElement } from "../../types";
|
||||
|
||||
type OpenAutofillOverlayOptions = {
|
||||
export type OpenAutofillInlineMenuOptions = {
|
||||
isFocusingFieldElement?: boolean;
|
||||
isOpeningFullOverlay?: boolean;
|
||||
isOpeningFullInlineMenu?: boolean;
|
||||
authStatus?: AuthenticationStatus;
|
||||
};
|
||||
|
||||
interface AutofillOverlayContentService {
|
||||
isFieldCurrentlyFocused: boolean;
|
||||
isCurrentlyFilling: boolean;
|
||||
isOverlayCiphersPopulated: boolean;
|
||||
export type SubFrameDataFromWindowMessage = SubFrameOffsetData & {
|
||||
subFrameDepth: number;
|
||||
};
|
||||
|
||||
export type AutofillOverlayContentExtensionMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
openAutofillInlineMenu: ({ message }: AutofillExtensionMessageParam) => void;
|
||||
addNewVaultItemFromOverlay: () => void;
|
||||
blurMostRecentlyFocusedField: () => void;
|
||||
unsetMostRecentlyFocusedField: () => void;
|
||||
checkIsMostRecentlyFocusedFieldWithinViewport: () => Promise<boolean>;
|
||||
bgUnlockPopoutOpened: () => void;
|
||||
bgVaultItemRepromptPopoutOpened: () => void;
|
||||
redirectAutofillInlineMenuFocusOut: ({ message }: AutofillExtensionMessageParam) => void;
|
||||
updateAutofillInlineMenuVisibility: ({ message }: AutofillExtensionMessageParam) => void;
|
||||
getSubFrameOffsets: ({ message }: AutofillExtensionMessageParam) => Promise<SubFrameOffsetData>;
|
||||
getSubFrameOffsetsFromWindowMessage: ({ message }: AutofillExtensionMessageParam) => void;
|
||||
checkMostRecentlyFocusedFieldHasValue: () => boolean;
|
||||
setupRebuildSubFrameOffsetsListeners: () => void;
|
||||
destroyAutofillInlineMenuListeners: () => void;
|
||||
};
|
||||
|
||||
export interface AutofillOverlayContentService {
|
||||
pageDetailsUpdateRequired: boolean;
|
||||
autofillOverlayVisibility: number;
|
||||
messageHandlers: AutofillOverlayContentExtensionMessageHandlers;
|
||||
init(): void;
|
||||
setupAutofillOverlayListenerOnField(
|
||||
setupInlineMenu(
|
||||
autofillFieldElement: ElementWithOpId<FormFieldElement>,
|
||||
autofillFieldData: AutofillField,
|
||||
pageDetails: AutofillPageDetails,
|
||||
): Promise<void>;
|
||||
openAutofillOverlay(options: OpenAutofillOverlayOptions): void;
|
||||
removeAutofillOverlay(): void;
|
||||
removeAutofillOverlayButton(): void;
|
||||
removeAutofillOverlayList(): void;
|
||||
addNewVaultItem(): void;
|
||||
redirectOverlayFocusOut(direction: "previous" | "next"): void;
|
||||
focusMostRecentOverlayField(): void;
|
||||
blurMostRecentOverlayField(): void;
|
||||
blurMostRecentlyFocusedField(isClosingInlineMenu?: boolean): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export { OpenAutofillOverlayOptions, AutofillOverlayContentService };
|
||||
|
@ -1,6 +1,6 @@
|
||||
import AutofillField from "../../models/autofill-field";
|
||||
import AutofillPageDetails from "../../models/autofill-page-details";
|
||||
|
||||
export interface InlineMenuFieldQualificationsService {
|
||||
export interface InlineMenuFieldQualificationService {
|
||||
isFieldForLoginForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean;
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ export class AutoFillConstants {
|
||||
...AutoFillConstants.ExcludedAutofillLoginTypes,
|
||||
];
|
||||
|
||||
static readonly ExcludedOverlayTypes: string[] = [
|
||||
static readonly ExcludedInlineMenuTypes: string[] = [
|
||||
"textarea",
|
||||
...AutoFillConstants.ExcludedAutofillTypes,
|
||||
];
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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<CipherService>();
|
||||
let inlineMenuVisibilityMock$!: BehaviorSubject<InlineMenuVisibilitySetting>;
|
||||
let autofillSettingsService: MockProxy<AutofillSettingsService>;
|
||||
let autofillSettingsService: MockProxy<AutofillSettingsServiceAbstraction>;
|
||||
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<PlatformUtilsService>();
|
||||
let activeAccountStatusMock$: BehaviorSubject<AuthenticationStatus>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let messageListener: MockProxy<MessageListener>;
|
||||
|
||||
beforeEach(() => {
|
||||
scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService);
|
||||
inlineMenuVisibilityMock$ = new BehaviorSubject(AutofillOverlayVisibility.OnFieldFocus);
|
||||
autofillSettingsService = mock<AutofillSettingsService>();
|
||||
(autofillSettingsService as any).inlineMenuVisibility$ = inlineMenuVisibilityMock$;
|
||||
autofillSettingsService = mock<AutofillSettingsServiceAbstraction>();
|
||||
autofillSettingsService.inlineMenuVisibility$ = inlineMenuVisibilityMock$;
|
||||
activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked);
|
||||
authService = mock<AuthService>();
|
||||
authService.activeAccountStatus$ = activeAccountStatusMock$;
|
||||
configService = mock<ConfigService>();
|
||||
messageListener = mock<MessageListener>();
|
||||
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<chrome.webNavigation.GetAllFrameResultDetails>({ 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);
|
||||
|
@ -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<InlineMenuVisibilitySetting> {
|
||||
async getInlineMenuVisibility(): Promise<InlineMenuVisibilitySetting> {
|
||||
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;
|
||||
|
@ -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<InlineMenuFieldQualificationService>();
|
||||
const autofillOverlayContentService = new AutofillOverlayContentService(
|
||||
inlineMenuFieldQualificationService,
|
||||
);
|
||||
let collectAutofillContentService: CollectAutofillContentService;
|
||||
const mockIntersectionObserver = mock<IntersectionObserver>();
|
||||
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<FormFieldElement>;
|
||||
const autofillField = mock<AutofillField>();
|
||||
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<FormFieldElement>;
|
||||
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<FormFieldElement>;
|
||||
const autofillField = mock<AutofillField>();
|
||||
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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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<FormFieldElement>} element
|
||||
* @param {string} attributeName
|
||||
* @param {boolean} checkString
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
private getAttributeBoolean(
|
||||
element: ElementWithOpId<FormFieldElement>,
|
||||
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<FormFieldElement>} 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<FormFieldElement>,
|
||||
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,
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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<InlineMenuFieldQualificationService>();
|
||||
const domElementVisibilityService = new DomElementVisibilityService();
|
||||
const autofillOverlayContentService = new AutofillOverlayContentService();
|
||||
const autofillOverlayContentService = new AutofillOverlayContentService(
|
||||
inlineMenuFieldQualificationService,
|
||||
);
|
||||
const collectAutofillContentService = new CollectAutofillContentService(
|
||||
domElementVisibilityService,
|
||||
autofillOverlayContentService,
|
||||
|
@ -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<string, string>,
|
||||
): 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<chrome.runtime.Port>({
|
||||
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<NodeList>(),
|
||||
attributeName: "default-attributeName",
|
||||
attributeNamespace: "default-attributeNamespace",
|
||||
nextSibling: null,
|
||||
oldValue: "default-oldValue",
|
||||
previousSibling: null,
|
||||
removedNodes: mock<NodeList>(),
|
||||
target: null,
|
||||
type: "attributes",
|
||||
...customFields,
|
||||
};
|
||||
}
|
||||
|
@ -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) {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user