diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3faae48627b..7d216ac257e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -71,6 +71,7 @@ apps/web/src/app/platform @bitwarden/team-platform-dev libs/angular/src/platform @bitwarden/team-platform-dev libs/common/src/platform @bitwarden/team-platform-dev libs/common/spec @bitwarden/team-platform-dev +libs/common/src/state-migrations @bitwarden/team-platform-dev # Node-specifc platform files libs/node @bitwarden/team-platform-dev # Web utils used across app and connectors diff --git a/.github/renovate.json b/.github/renovate.json index af418cf3475..bb5816d4ce3 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -91,6 +91,7 @@ }, { "matchPackageNames": [ + "@webcomponents/custom-elements", "concurrently", "cross-env", "del", @@ -105,6 +106,7 @@ "prettier", "prettier-plugin-tailwindcss", "rimraf", + "tabbable", "wait-on" ], "description": "Autofill owned dependencies", diff --git a/.storybook/manager.js b/.storybook/manager.js new file mode 100644 index 00000000000..89a69bf9421 --- /dev/null +++ b/.storybook/manager.js @@ -0,0 +1,63 @@ +import { addons } from "@storybook/addons"; +import { create } from "@storybook/theming/create"; + +const lightTheme = create({ + base: "light", + //logo and Title + brandTitle: "Bitwarden Component Library", + brandUrl: "/", + brandImage: + "https://github.com/bitwarden/brand/blob/51942f8d6e55e96a078a524e0f739efbf1997bcf/logos/logo-horizontal-blue.png?raw=true", + brandTarget: "_self", + + //Colors + colorPrimary: "#6D757E", + colorSecondary: "#175DDC", + + // UI + appBg: "#f9fBff", + appContentBg: "#ffffff", + appBorderColor: "#CED4DC", + + // Text colors + textColor: "#212529", + textInverseColor: "#ffffff", + + // Toolbar default and active colors + barTextColor: "#6D757E", + barSelectedColor: "#175DDC", + barBg: "#ffffff", + + // Form colors + inputBg: "#ffffff", + inputBorder: "#6D757E", + inputTextColor: "#6D757E", +}); + +const darkTheme = create({ + base: "dark", + + //logo and Title + brandTitle: "Bitwarden Component Library", + brandUrl: "/", + brandImage: + "https://github.com/bitwarden/brand/blob/51942f8d6e55e96a078a524e0f739efbf1997bcf/logos/logo-horizontal-white.png?raw=true", + brandTarget: "_self", + + //Colors + colorSecondary: "#6A99F0", + barSelectedColor: "#6A99F0", +}); + +export const getPreferredColorScheme = () => { + if (!globalThis || !globalThis.matchMedia) return "light"; + + const isDarkThemePreferred = globalThis.matchMedia("(prefers-color-scheme: dark)").matches; + if (isDarkThemePreferred) return "dark"; + + return "light"; +}; + +addons.setConfig({ + theme: getPreferredColorScheme() === "dark" ? darkTheme : lightTheme, +}); diff --git a/apps/browser/gulpfile.js b/apps/browser/gulpfile.js index 2f672ffcdd4..af1a2d8c7db 100644 --- a/apps/browser/gulpfile.js +++ b/apps/browser/gulpfile.js @@ -60,6 +60,7 @@ function dist(browserName, manifest) { function distFirefox() { return dist("firefox", (manifest) => { delete manifest.storage; + delete manifest.sandbox; return manifest; }); } diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 096ba374f2f..7278c61d448 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -92,13 +92,13 @@ "message": "Аўтазапаўненне" }, "autoFillLogin": { - "message": "Auto-fill login" + "message": "Аўтазапаўненне лагіна" }, "autoFillCard": { - "message": "Auto-fill card" + "message": "Аўтазапаўненне карткі" }, "autoFillIdentity": { - "message": "Auto-fill identity" + "message": "Аўтазапаўненне асабістых даных" }, "generatePasswordCopied": { "message": "Генерыраваць пароль (з капіяваннем)" @@ -110,13 +110,13 @@ "message": "Няма адпаведных лагінаў." }, "noCards": { - "message": "No cards" + "message": "Няма картак" }, "noIdentities": { - "message": "No identities" + "message": "Няма прыватных даных" }, "addLoginMenu": { - "message": "Add login" + "message": "Дадаць лагін" }, "addCardMenu": { "message": "Add card" diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 216d248d62c..dfa2260546c 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -442,7 +442,7 @@ "message": "При заключване на системата" }, "onRestart": { - "message": "При повторно пускане на четеца" + "message": "При повторно пускане на браузъра" }, "never": { "message": "Никога" @@ -2191,13 +2191,13 @@ "message": "Вписването е стартирано" }, "exposedMasterPassword": { - "message": "Exposed Master Password" + "message": "Разобличена главна парола" }, "exposedMasterPasswordDesc": { "message": "Паролата е намерена в пробив на данни. Използвайте уникална парола, за да защитите вашия акаунт. Наистина ли искате да използвате слаба парола?" }, "weakAndExposedMasterPassword": { - "message": "Weak and Exposed Master Password" + "message": "Слаба и разобличена главна парола" }, "weakAndBreachedMasterPasswordDesc": { "message": "Разпозната е слаба парола, която присъства в известен случай на изтекли данни. Използвайте сложна и уникална парола, за да защитите данните си. Наистина ли искате да използвате тази парола?" @@ -2575,7 +2575,7 @@ "message": "Секретният ключ няма да бъде копиран в клонирания елемент. Искате ли да продължите с клонирането на елемента?" }, "passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword": { - "message": "Verification required by the initiating site. This feature is not yet implemented for accounts without master password." + "message": "Изисква се проверка от иницииращия сайт. Тази функция все още не е внедрена за акаунти без главна парола." }, "logInWithPasskey": { "message": "Вписване със секретен ключ?" diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 355c1ec3b8f..af2de0643ed 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -619,6 +619,9 @@ "addLoginNotificationDesc": { "message": "Ask to add an item if one isn't found in your vault." }, + "addLoginNotificationDescAlt": { + "message": "Ask to add an item if one isn't found in your vault. Applies to all logged in accounts." + }, "showCardsCurrentTab": { "message": "Show cards on Tab page" }, @@ -651,6 +654,9 @@ "changedPasswordNotificationDesc": { "message": "Ask to update a login's password when a change is detected on a website." }, + "changedPasswordNotificationDescAlt": { + "message": "Ask to update a login's password when a change is detected on a website. Applies to all logged in accounts." + }, "notificationChangeDesc": { "message": "Do you want to update this password in Bitwarden?" }, @@ -667,7 +673,10 @@ "message": "Show context menu options" }, "contextMenuItemDesc": { - "message": "Use a secondary click to access password generation and matching logins for the website. " + "message": "Use a secondary click to access password generation and matching logins for the website." + }, + "contextMenuItemDescAlt": { + "message": "Use a secondary click to access password generation and matching logins for the website. Applies to all logged in accounts." }, "defaultUriMatchDetection": { "message": "Default URI match detection", @@ -682,6 +691,9 @@ "themeDesc": { "message": "Change the application's color theme." }, + "themeDescAlt": { + "message": "Change the application's color theme. Applies to all logged in accounts." + }, "dark": { "message": "Dark", "description": "Dark color" @@ -1002,6 +1014,25 @@ "environmentSaved": { "message": "Environment URLs saved" }, + "showAutoFillMenuOnFormFields": { + "message": "Show auto-fill menu on form fields", + "description": "Represents the message for allowing the user to enable the auto-fill overlay" + }, + "showAutoFillMenuOnFormFieldsDescAlt": { + "message": "Applies to all logged in accounts." + }, + "autofillOverlayVisibilityOff": { + "message": "Off", + "description": "Overlay setting select option for disabling autofill overlay" + }, + "autofillOverlayVisibilityOnFieldFocus": { + "message": "When field is selected (on focus)", + "description": "Overlay appearance select option for showing the field on focus of the input element" + }, + "autofillOverlayVisibilityOnButtonClick": { + "message": "When auto-fill icon is selected", + "description": "Overlay appearance select option for showing the field on click of the overlay icon" + }, "enableAutoFillOnPageLoad": { "message": "Auto-fill on page load" }, @@ -1253,9 +1284,6 @@ "typeIdentity": { "message": "Identity" }, - "typePasskey": { - "message": "Passkey" - }, "passwordHistory": { "message": "Password history" }, @@ -1665,6 +1693,9 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, + "excludedDomainsDescAlt": { + "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." + }, "excludedDomainsInvalidDomain": { "message": "$DOMAIN$ is not a valid domain", "placeholders": { @@ -2227,7 +2258,7 @@ "message": "How to auto-fill" }, "autofillSelectInfoWithCommand": { - "message": "Select an item from this page or use the shortcut: $COMMAND$", + "message": "Select an item from this screen, use the shortcut $COMMAND$, or explore other options in settings.", "placeholders": { "command": { "content": "$1", @@ -2236,7 +2267,7 @@ } }, "autofillSelectInfoWithoutCommand": { - "message": "Select an item from this page or set a shortcut in settings." + "message": "Select an item from this screen, or explore other options in settings." }, "gotIt": { "message": "Got it" @@ -2457,6 +2488,80 @@ "message": "Turn off master password re-prompt to edit this field", "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." }, + "bitwardenOverlayButton": { + "message": "Bitwarden auto-fill menu button", + "description": "Page title for the iframe containing the overlay button" + }, + "toggleBitwardenVaultOverlay": { + "message": "Toggle Bitwarden auto-fill menu", + "description": "Screen reader and tool tip label for the overlay button" + }, + "bitwardenVault": { + "message": "Bitwarden auto-fill menu", + "description": "Page title in overlay" + }, + "unlockYourAccountToViewMatchingLogins": { + "message": "Unlock your account to view matching logins", + "description": "Text to display in overlay when the account is locked." + }, + "unlockAccount": { + "message": "Unlock account", + "description": "Button text to display in overlay when the account is locked." + }, + "fillCredentialsFor": { + "message": "Fill credentials for", + "description": "Screen reader text for when overlay item is in focused" + }, + "partialUsername" : { + "message": "Partial username", + "description": "Screen reader text for when a login item is focused where a partial username is displayed. SR will announce this phrase before reading the text of the partial username" + }, + "noItemsToShow": { + "message": "No items to show", + "description": "Text to show in overlay if there are no matching items" + }, + "newItem": { + "message": "New item", + "description": "Button text to display in overlay when there are no matching items" + }, + "addNewVaultItem": { + "message": "Add new vault item", + "description": "Screen reader text (aria-label) for new item button in overlay" + }, + "bitwardenOverlayMenuAvailable": { + "message": "Bitwarden auto-fill menu available. Press the down arrow key to select.", + "description": "Screen reader text for announcing when the overlay opens on the page" + }, + "overrideBrowserAutofillTitle": { + "message": "Override browser auto-fill?", + "description": "Dialog title facilitating the ability to override a chrome browser's default autofill behavior" + }, + "overrideBrowserAutofillDescription": { + "message": "Leaving this setting off may cause conflicts between the Bitwarden auto-fill menu and your browser’s.", + "description": "Dialog message facilitating the ability to override a chrome browser's default autofill behavior" + }, + "overrideBrowserAutofillPrivacyRequiredDescription": { + "message": "Leaving this setting off may cause conflicts between the Bitwarden auto-fill menu and your browser’s. Turning this on will restart the Bitwarden extension.", + "description": "Dialog message facilitating the ability to override a chrome browser's default autofill behavior" + }, + "turnOn": { + "message": "Turn on" + }, + "ignore": { + "message": "Ignore" + }, + "overrideBrowserAutoFillSettings": { + "message": "Override browser auto-fill settings", + "description": "Label for the setting that allows overriding the default browser autofill settings" + }, + "extensionPrivacyPermissionNotGrantedTitle": { + "message": "Unable to override browser auto-fill", + "description": "Title for the dialog that appears when the user has not granted the extension permission to set privacy settings" + }, + "extensionPrivacyPermissionNotGrantedDescription": { + "message": "Bitwarden must have access to the extension's privacy permission to override browser auto-fill settings.", + "description": "Description for the dialog that appears when the user has not granted the extension permission to set privacy settings" + }, "importData": { "message": "Import data", "description": "Used for the header of the import dialog, the import button and within the file-password-prompt" diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 66a91b70fe5..6d321b41904 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -92,13 +92,13 @@ "message": "Auto-ispuna" }, "autoFillLogin": { - "message": "Auto-fill login" + "message": "Auto-ispuna prijave" }, "autoFillCard": { - "message": "Auto-fill card" + "message": "Auto-ispuna kartice" }, "autoFillIdentity": { - "message": "Auto-fill identity" + "message": "Auto-ispuna identiteta" }, "generatePasswordCopied": { "message": "Generiraj lozinku (i kopiraj)" @@ -110,19 +110,19 @@ "message": "Nema podudarajućih prijava" }, "noCards": { - "message": "No cards" + "message": "Nema kartica" }, "noIdentities": { - "message": "No identities" + "message": "Nema identiteta" }, "addLoginMenu": { - "message": "Add login" + "message": "Dodaj prijavu" }, "addCardMenu": { - "message": "Add card" + "message": "Dodaj karticu" }, "addIdentityMenu": { - "message": "Add identity" + "message": "Dodaj identitet" }, "unlockVaultMenu": { "message": "Otključaj svoj trezor" @@ -1254,7 +1254,7 @@ "message": "Identitet" }, "typePasskey": { - "message": "Passkey" + "message": "Pristupni ključ" }, "passwordHistory": { "message": "Povijest" @@ -1657,7 +1657,7 @@ "message": "Pravila organizacije utječu na tvoje mogućnosti vlasništva. " }, "personalOwnershipPolicyInEffectImports": { - "message": "An organization policy has blocked importing items into your individual vault." + "message": "Organizacijsko pravilo onemogućuje uvoz stavki u tvoj osobni trezor." }, "excludedDomains": { "message": "Izuzete domene" @@ -1926,11 +1926,11 @@ "message": "Odaberi mapu..." }, "orgPermissionsUpdatedMustSetPassword": { - "message": "Your organization permissions were updated, requiring you to set a master password.", + "message": "Moraš postaviti glavnu lozinku jer su dopuštenja tvoje organizacije ažurirana.", "description": "Used as a card title description on the set password page to explain why the user is there" }, "orgRequiresYouToSetPassword": { - "message": "Your organization requires you to set a master password.", + "message": "Tvoja organizacija zahtijeva da postaviš glavnu lozinku.", "description": "Used as a card title description on the set password page to explain why the user is there" }, "hours": { @@ -2458,26 +2458,26 @@ "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." }, "importData": { - "message": "Import data", + "message": "Uvezi podatke", "description": "Used for the header of the import dialog, the import button and within the file-password-prompt" }, "importError": { - "message": "Import error" + "message": "Greška prilikom uvoza" }, "importErrorDesc": { - "message": "There was a problem with the data you tried to import. Please resolve the errors listed below in your source file and try again." + "message": "Postoji problem s podacima za uvoz. Potrebno je razriješiti doljenavedene greške u izvornoj datoteci i pokušati ponovno." }, "resolveTheErrorsBelowAndTryAgain": { - "message": "Resolve the errors below and try again." + "message": "Popravi navedene greške i pokušaj ponovo." }, "description": { - "message": "Description" + "message": "Opis" }, "importSuccess": { - "message": "Data successfully imported" + "message": "Uvoz podataka u trezor je uspio" }, "importSuccessNumberOfItems": { - "message": "A total of $AMOUNT$ items were imported.", + "message": "Ukupno je uvezeno $AMOUNT$ stavaka.", "placeholders": { "amount": { "content": "$1", @@ -2486,10 +2486,10 @@ } }, "total": { - "message": "Total" + "message": "Ukupno" }, "importWarning": { - "message": "You are importing data to $ORGANIZATION$. Your data may be shared with members of this organization. Do you want to proceed?", + "message": "Uvoziš podatke u $ORGANIZATION$. Tvoji podaci možda će biti podijeljeni s članovima ove organizacije. Želiš li svejedno uvesti podatke?", "placeholders": { "organization": { "content": "$1", @@ -2498,31 +2498,31 @@ } }, "importFormatError": { - "message": "Data is not formatted correctly. Please check your import file and try again." + "message": "Podaci nisu ispravno formatirani. Provjeri uvoznu datoteku i pokušaj ponovno." }, "importNothingError": { - "message": "Nothing was imported." + "message": "Ništa nije uvezeno." }, "importEncKeyError": { - "message": "Error decrypting the exported file. Your encryption key does not match the encryption key used export the data." + "message": "Greška u dešifriranju izvozne datoteke. Ovaj ključ za šifriranje ne odgovara ključu za šifriranje korištenom pri izvozu datoteke." }, "invalidFilePassword": { - "message": "Invalid file password, please use the password you entered when you created the export file." + "message": "Nesipravna lozinka datoteke. Unesi lozinku izvozne datoteke." }, "importDestination": { - "message": "Import destination" + "message": "Odredište uvoza" }, "learnAboutImportOptions": { - "message": "Learn about your import options" + "message": "Više o mogućnostima uvoza" }, "selectImportFolder": { - "message": "Select a folder" + "message": "Odaberi mapu" }, "selectImportCollection": { - "message": "Select a collection" + "message": "Odaberi zbirku" }, "importTargetHint": { - "message": "Select this option if you want the imported file contents moved to a $DESTINATION$", + "message": "Odaberi ovu opciju ako sadržaj uvezene datoteke želiš spremiti u $DESTINATION$", "description": "Located as a hint under the import target. Will be appended by either folder or collection, depending if the user is importing into an individual or an organizational vault.", "placeholders": { "destination": { @@ -2532,25 +2532,25 @@ } }, "importUnassignedItemsError": { - "message": "File contains unassigned items." + "message": "Datoteka sadrži nedodijeljene stavke." }, "selectFormat": { - "message": "Select the format of the import file" + "message": "Odaberi format datoteke za uvoz" }, "selectImportFile": { - "message": "Select the import file" + "message": "Odaberi datoteku za uvoz" }, "chooseFile": { - "message": "Choose File" + "message": "Odaberi datoteku" }, "noFileChosen": { - "message": "No file chosen" + "message": "Nije odabrana datoteka" }, "orCopyPasteFileContents": { - "message": "or copy/paste the import file contents" + "message": "ili kopiraj/zalijepi sadržaj uvozne datoteke" }, "instructionsFor": { - "message": "$NAME$ Instructions", + "message": "$NAME$ upute", "description": "The title for the import tool instructions.", "placeholders": { "name": { @@ -2560,127 +2560,127 @@ } }, "confirmVaultImport": { - "message": "Confirm vault import" + "message": "Potvrdi uvoz trezora" }, "confirmVaultImportDesc": { - "message": "This file is password-protected. Please enter the file password to import data." + "message": "Ova je datoteka zaštićena lozinkom. Unesi lozinku za nastavak uvoza." }, "confirmFilePassword": { - "message": "Confirm file password" + "message": "Potvrdi lozinku datoteke" }, "passkeyNotCopied": { - "message": "Passkey will not be copied" + "message": "Pristupni ključ neće biti kopiran" }, "passkeyNotCopiedAlert": { - "message": "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?" + "message": "Pristupni ključ se neće kopirati u kloniranu stavku. Želiš li nastaviti klonirati ovu stavku?" }, "passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword": { - "message": "Verification required by the initiating site. This feature is not yet implemented for accounts without master password." + "message": "Ishodišna stranica zahtijeva verifikaciju. Ova značajka još nije implementirana za račune bez glavne lozinke." }, "logInWithPasskey": { - "message": "Log in with passkey?" + "message": "Prijava pristupnim ključem?" }, "passkeyAlreadyExists": { - "message": "A passkey already exists for this application." + "message": "Za ovu aplikaciju već postoji pristupni ključ." }, "noPasskeysFoundForThisApplication": { - "message": "No passkeys found for this application." + "message": "Za ovu aplikaciju nema pristupnih ključeva." }, "noMatchingPasskeyLogin": { - "message": "You do not have a matching login for this site." + "message": "Nema odgovarajuće prijavu za ovu stranicu." }, "confirm": { - "message": "Confirm" + "message": "Autoriziraj" }, "savePasskey": { - "message": "Save passkey" + "message": "Spremi pristupni ključ" }, "savePasskeyNewLogin": { - "message": "Save passkey as new login" + "message": "Spremi pristupni ključ kao novu prijavu" }, "choosePasskey": { - "message": "Choose a login to save this passkey to" + "message": "Odaberi prijavu za spremanje ovog pristupnog ključa" }, "passkeyItem": { - "message": "Passkey Item" + "message": "Stavka pristupnog ključa" }, "overwritePasskey": { - "message": "Overwrite passkey?" + "message": "Prebriši pristupni ključ?" }, "overwritePasskeyAlert": { - "message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?" + "message": "Ova stavka već sadrži pristupni ključ. Sigurno želiš prebrisati trenutni pristupni ključ?" }, "featureNotSupported": { - "message": "Feature not yet supported" + "message": "Značajka još nije podržana" }, "yourPasskeyIsLocked": { - "message": "Authentication required to use passkey. Verify your identity to continue." + "message": "Za korištenje pristpnog ključa potrebna je autentifikacija. Potvrdi svoj identitet." }, "useBrowserName": { - "message": "Use browser" + "message": "Koristi preglednik" }, "multifactorAuthenticationCancelled": { - "message": "Multifactor authentication cancelled" + "message": "Multifaktorska autentifikacija otkazana" }, "noLastPassDataFound": { - "message": "No LastPass data found" + "message": "Nisu nađeni LastPass podaci" }, "incorrectUsernameOrPassword": { - "message": "Incorrect username or password" + "message": "Neispravno korisničko ime ili lozinka" }, "multifactorAuthenticationFailed": { - "message": "Multifactor authentication failed" + "message": "Multifaktorska autentifikacija nije uspjela" }, "includeSharedFolders": { - "message": "Include shared folders" + "message": "Uključi dijeljene mape" }, "lastPassEmail": { - "message": "LastPass Email" + "message": "LastPass e-pošta" }, "importingYourAccount": { - "message": "Importing your account..." + "message": "Uvoz tvog računa..." }, "lastPassMFARequired": { - "message": "LastPass multifactor authentication required" + "message": "Potrebna LastPass multifaktorska autenfikacija" }, "lastPassMFADesc": { - "message": "Enter your one-time passcode from your authentication app" + "message": "Unesi svoj jednokratni kôd iz aplikacije za autentifikaciju" }, "lastPassOOBDesc": { - "message": "Approve the login request in your authentication app or enter a one-time passcode." + "message": "Odobri svoj zahtjev za prijavu u svojoj aplikaciji za autentifikaciju ili unesi jednokratni kôd." }, "passcode": { "message": "Passcode" }, "lastPassMasterPassword": { - "message": "LastPass master password" + "message": "LastPass glavna lozinka" }, "lastPassAuthRequired": { - "message": "LastPass authentication required" + "message": "Potrebna LastPass autentifikacija" }, "awaitingSSO": { - "message": "Awaiting SSO authentication" + "message": "Čekanje SSO autentifikacije" }, "awaitingSSODesc": { - "message": "Please continue to log in using your company credentials." + "message": "Prijavi se koristeći pristupne podatke svoje tvrtke." }, "seeDetailedInstructions": { - "message": "See detailed instructions on our help site at", + "message": "Detaljne upute za pomoć pronađi na našoj stranici za pomoć na", "description": "This is followed a by a hyperlink to the help website." }, "importDirectlyFromLastPass": { - "message": "Import directly from LastPass" + "message": "Uvezi direktno iz LastPass-a" }, "importFromCSV": { - "message": "Import from CSV" + "message": "Uvezi iz CSV-a" }, "lastPassTryAgainCheckEmail": { - "message": "Try again or look for an email from LastPass to verify it's you." + "message": "Pokušaj ponovno ili pogledaj e-poštu od LastPass-a za potvrdu." }, "collection": { - "message": "Collection" + "message": "Zbirka" }, "lastPassYubikeyDesc": { - "message": "Insert the YubiKey associated with your LastPass account into your computer's USB port, then touch its button." + "message": "Umetni YubiKey pridružen svojem LastPass računu u USB priključak račuanala, a zatim dodirni njegovu tipku." } } diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 6f637a10dfe..1c72b15db49 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -510,7 +510,7 @@ "message": "您的登录会话已过期。" }, "logOutConfirmation": { - "message": "您确定要注销吗?" + "message": "确定要注销吗?" }, "yes": { "message": "是" @@ -540,7 +540,7 @@ "message": "文件夹已保存" }, "deleteFolderConfirmation": { - "message": "您确定要删除此文件夹吗?" + "message": "确定要删除此文件夹吗?" }, "deletedFolder": { "message": "文件夹已删除" @@ -592,13 +592,13 @@ "message": "覆盖密码" }, "overwritePasswordConfirmation": { - "message": "您确定要覆盖当前密码吗?" + "message": "确定要覆盖当前密码吗?" }, "overwriteUsername": { "message": "覆盖用户名" }, "overwriteUsernameConfirmation": { - "message": "您确定要覆盖当前用户名吗?" + "message": "确定要覆盖当前用户名吗?" }, "searchFolder": { "message": "搜索文件夹" @@ -769,7 +769,7 @@ "message": "删除附件" }, "deleteAttachmentConfirmation": { - "message": "您确定要删除此附件吗?" + "message": "确定要删除此附件吗?" }, "deletedAttachment": { "message": "附件已删除" @@ -1378,7 +1378,7 @@ "description": "ex. Date this password was updated" }, "neverLockWarning": { - "message": "您确定要使用「从不」选项吗?将锁定选项设置为「从不」会将密码库的加密密钥存储在您的设备上。如果使用此选项,您必须确保您的设备安全。" + "message": "确定要使用「从不」选项吗?将锁定选项设置为「从不」会将密码库的加密密钥存储在您的设备上。如果使用此选项,您必须确保您的设备安全。" }, "noOrganizationsList": { "message": "您没有加入任何组织。组织允许您与其他用户安全地共享项目。" @@ -1468,7 +1468,7 @@ "message": "永久删除项目" }, "permanentlyDeleteItemConfirmation": { - "message": "您确定要永久删除此项目吗?" + "message": "确定要永久删除此项目吗?" }, "permanentlyDeletedItem": { "message": "项目已永久删除" @@ -2012,7 +2012,7 @@ "message": "主密码已移除" }, "leaveOrganizationConfirmation": { - "message": "您确定要退出该组织吗?" + "message": "确定要退出该组织吗?" }, "leftOrganization": { "message": "您已经退出该组织。" @@ -2522,7 +2522,7 @@ "message": "选择一个集合" }, "importTargetHint": { - "message": "如果您希望将导入的文件内容移动到 $DESTINATION$,请选择此选项", + "message": "如果您希望将导入的文件内容移动到某个 $DESTINATION$,请选择此选项", "description": "Located as a hint under the import target. Will be appended by either folder or collection, depending if the user is importing into an individual or an organizational vault.", "placeholders": { "destination": { @@ -2608,7 +2608,7 @@ "message": "覆盖通行密钥吗?" }, "overwritePasskeyAlert": { - "message": "此项目已包含一个通行密钥。您确定要覆盖当前的通行密钥吗?" + "message": "此项目已包含一个通行密钥。确定要覆盖当前的通行密钥吗?" }, "featureNotSupported": { "message": "功能尚不被支持" @@ -2632,7 +2632,7 @@ "message": "多重身份验证失败" }, "includeSharedFolders": { - "message": "包含已共享的文件夹" + "message": "包含共享的文件夹" }, "lastPassEmail": { "message": "LastPass Email" diff --git a/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts b/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts index 1cc0d2948dc..e682181454e 100644 --- a/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts +++ b/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts @@ -23,9 +23,14 @@ describe("AuthPopoutWindow", () => { }); describe("openUnlockPopout", () => { + let senderTab: chrome.tabs.Tab; + + beforeEach(() => { + senderTab = { windowId: 1 } as chrome.tabs.Tab; + }); + it("opens a single action popup that allows the user to unlock the extension and sends a `bgUnlockPopoutOpened` message", async () => { jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([]); - const senderTab = { windowId: 1 } as chrome.tabs.Tab; await openUnlockPopout(senderTab); @@ -33,7 +38,17 @@ describe("AuthPopoutWindow", () => { singleActionKey: AuthPopoutType.unlockExtension, senderWindowId: 1, }); - expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened"); + expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened", { + skipNotification: false, + }); + }); + + it("sends an indication that the presenting the notification bar for unlocking the extension should be skipped", async () => { + await openUnlockPopout(senderTab, true); + + expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened", { + skipNotification: true, + }); }); it("closes any existing popup window types that are open to the unlock extension route", async () => { @@ -65,16 +80,16 @@ describe("AuthPopoutWindow", () => { }); describe("closeUnlockPopout", () => { - it("closes the unlock extension popout window", () => { - closeUnlockPopout(); + it("closes the unlock extension popout window", async () => { + await closeUnlockPopout(); - expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith("auth_unlockExtension"); + expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(AuthPopoutType.unlockExtension); }); }); describe("openSsoAuthResultPopout", () => { - it("opens a window that facilitates presentation of the results for SSO authentication", () => { - openSsoAuthResultPopout({ code: "code", state: "state" }); + it("opens a window that facilitates presentation of the results for SSO authentication", async () => { + await openSsoAuthResultPopout({ code: "code", state: "state" }); expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/sso?code=code&state=state", { singleActionKey: AuthPopoutType.ssoAuthResult, @@ -83,8 +98,8 @@ describe("AuthPopoutWindow", () => { }); describe("openTwoFactorAuthPopout", () => { - it("opens a window that facilitates two factor authentication", () => { - openTwoFactorAuthPopout({ data: "data", remember: "remember" }); + it("opens a window that facilitates two factor authentication", async () => { + await openTwoFactorAuthPopout({ data: "data", remember: "remember" }); expect(openPopoutSpy).toHaveBeenCalledWith( "popup/index.html#/2fa;webAuthnResponse=data;remember=remember", @@ -94,10 +109,10 @@ describe("AuthPopoutWindow", () => { }); describe("closeTwoFactorAuthPopout", () => { - it("closes the two-factor authentication window", () => { - closeTwoFactorAuthPopout(); + it("closes the two-factor authentication window", async () => { + await closeTwoFactorAuthPopout(); - expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith("auth_twoFactorAuth"); + expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(AuthPopoutType.twoFactorAuth); }); }); }); diff --git a/apps/browser/src/auth/popup/utils/auth-popout-window.ts b/apps/browser/src/auth/popup/utils/auth-popout-window.ts index 2f995fc1f4a..d436aeb73eb 100644 --- a/apps/browser/src/auth/popup/utils/auth-popout-window.ts +++ b/apps/browser/src/auth/popup/utils/auth-popout-window.ts @@ -15,8 +15,9 @@ const extensionUnlockUrls = new Set([ * Opens a window that facilitates unlocking / logging into the extension. * * @param senderTab - Used to determine the windowId of the sender. + * @param skipNotification - Used to determine whether to show the unlock notification. */ -async function openUnlockPopout(senderTab: chrome.tabs.Tab) { +async function openUnlockPopout(senderTab: chrome.tabs.Tab, skipNotification = false) { const existingPopoutWindowTabs = await BrowserApi.tabsQuery({ windowType: "popup" }); existingPopoutWindowTabs.forEach((tab) => { if (extensionUnlockUrls.has(tab.url)) { @@ -28,7 +29,7 @@ async function openUnlockPopout(senderTab: chrome.tabs.Tab) { singleActionKey: AuthPopoutType.unlockExtension, senderWindowId: senderTab.windowId, }); - await BrowserApi.tabSendMessageData(senderTab, "bgUnlockPopoutOpened"); + await BrowserApi.tabSendMessageData(senderTab, "bgUnlockPopoutOpened", { skipNotification }); } /** diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts new file mode 100644 index 00000000000..2e5eb1bfa63 --- /dev/null +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -0,0 +1,131 @@ +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; + +import AutofillPageDetails from "../../models/autofill-page-details"; + +type WebsiteIconData = { + imageEnabled: boolean; + image: string; + fallbackImage: string; + icon: string; +}; + +type OverlayAddNewItemMessage = { + login?: { + uri?: string; + hostname: string; + username: string; + password: string; + }; +}; + +type OverlayBackgroundExtensionMessage = { + [key: string]: any; + command: string; + tab?: chrome.tabs.Tab; + sender?: string; + details?: AutofillPageDetails; + overlayElement?: string; + display?: string; + data?: { + commandToRetry?: { + msg?: { + command?: string; + }; + }; + }; +} & OverlayAddNewItemMessage; + +type OverlayPortMessage = { + [key: string]: any; + command: string; + direction?: string; + overlayCipherId?: string; +}; + +type FocusedFieldData = { + focusedFieldStyles: Partial; + focusedFieldRects: Partial; +}; + +type OverlayCipherData = { + id: string; + name: string; + type: CipherType; + reprompt: CipherRepromptType; + favorite: boolean; + icon: { imageEnabled: boolean; image: string; fallbackImage: string; icon: string }; + login?: { username: string }; + card?: string; +}; + +type BackgroundMessageParam = { + message: OverlayBackgroundExtensionMessage; +}; +type BackgroundSenderParam = { + sender: chrome.runtime.MessageSender; +}; +type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; + +type OverlayBackgroundExtensionMessageHandlers = { + [key: string]: CallableFunction; + openAutofillOverlay: () => void; + autofillOverlayElementClosed: ({ message }: BackgroundMessageParam) => void; + autofillOverlayAddNewVaultItem: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + getAutofillOverlayVisibility: () => void; + checkAutofillOverlayFocused: () => void; + focusAutofillOverlayList: () => void; + updateAutofillOverlayPosition: ({ message }: BackgroundMessageParam) => void; + updateAutofillOverlayHidden: ({ message }: BackgroundMessageParam) => void; + updateFocusedFieldData: ({ message }: BackgroundMessageParam) => void; + collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + unlockCompleted: ({ message }: BackgroundMessageParam) => void; + addEditCipherSubmitted: () => void; + deletedCipher: () => void; +}; + +type PortMessageParam = { + message: OverlayPortMessage; +}; +type PortConnectionParam = { + port: chrome.runtime.Port; +}; +type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam; + +type OverlayButtonPortMessageHandlers = { + [key: string]: CallableFunction; + overlayButtonClicked: ({ port }: PortConnectionParam) => void; + closeAutofillOverlay: ({ port }: PortConnectionParam) => void; + overlayPageBlurred: () => void; + redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; +}; + +type OverlayListPortMessageHandlers = { + [key: string]: CallableFunction; + checkAutofillOverlayButtonFocused: () => void; + overlayPageBlurred: () => void; + unlockVault: ({ port }: PortConnectionParam) => void; + fillSelectedListItem: ({ message, port }: PortOnMessageHandlerParams) => void; + addNewVaultItem: ({ port }: PortConnectionParam) => void; + viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void; + redirectOverlayFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void; +}; + +interface OverlayBackground { + init(): Promise; + removePageDetails(tabId: number): void; + updateOverlayCiphers(): void; +} + +export { + WebsiteIconData, + OverlayBackgroundExtensionMessage, + OverlayPortMessage, + FocusedFieldData, + OverlayCipherData, + OverlayAddNewItemMessage, + OverlayBackgroundExtensionMessageHandlers, + OverlayButtonPortMessageHandlers, + OverlayListPortMessageHandlers, + OverlayBackground, +}; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts new file mode 100644 index 00000000000..6a0a4a7345a --- /dev/null +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -0,0 +1,54 @@ +import { mock } from "jest-mock-extended"; + +import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; +import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; +import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; + +import { BrowserStateService } from "../../platform/services/browser-state.service"; +import { createChromeTabMock } from "../jest/autofill-mocks"; +import AutofillService from "../services/autofill.service"; + +import NotificationBackground from "./notification.background"; + +describe("NotificationBackground", () => { + let notificationBackground: NotificationBackground; + const autofillService = mock(); + const cipherService = mock(); + const authService = mock(); + const policyService = mock(); + const folderService = mock(); + const stateService = mock(); + const environmentService = mock(); + + beforeEach(() => { + notificationBackground = new NotificationBackground( + autofillService, + cipherService, + authService, + policyService, + folderService, + stateService, + environmentService + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("unlockVault", () => { + it("returns early if the message indicates that the notification should be skipped", async () => { + const tabMock = createChromeTabMock(); + const message = { data: { skipNotification: true } }; + jest.spyOn(notificationBackground["authService"], "getAuthStatus"); + jest.spyOn(notificationBackground as any, "pushUnlockVaultToQueue"); + + await notificationBackground["unlockVault"](message, tabMock); + + expect(notificationBackground["authService"].getAuthStatus).not.toHaveBeenCalled(); + expect(notificationBackground["pushUnlockVaultToQueue"]).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 253dcd8928a..c60bc666d38 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -122,7 +122,7 @@ export default class NotificationBackground { } break; case "bgUnlockPopoutOpened": - await this.unlockVault(sender.tab); + await this.unlockVault(msg, sender.tab); break; case "checkNotificationQueue": await this.checkNotificationQueue(sender.tab); @@ -330,7 +330,21 @@ export default class NotificationBackground { } } - private async unlockVault(tab: chrome.tabs.Tab) { + /** + * Sets up a notification to unlock the vault when the user + * attempts to autofill a cipher while the vault is locked. + * + * @param message - Extension message, determines if the notification should be skipped + * @param tab - The tab that the message was sent from + */ + private async unlockVault( + message: { data?: { skipNotification?: boolean } }, + tab: chrome.tabs.Tab + ) { + if (message.data?.skipNotification) { + return; + } + const currentAuthStatus = await this.authService.getAuthStatus(); if (currentAuthStatus !== AuthenticationStatus.Locked || this.notificationQueue.length) { return; diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts new file mode 100644 index 00000000000..6f103b9c65a --- /dev/null +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -0,0 +1,1316 @@ +import { mock, mockReset } from "jest-mock-extended"; + +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { ThemeType } from "@bitwarden/common/enums"; +import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; +import { I18nService } from "@bitwarden/common/platform/services/i18n.service"; +import { SettingsService } from "@bitwarden/common/services/settings.service"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; + +import { BrowserApi } from "../../platform/browser/browser-api"; +import { BrowserStateService } from "../../platform/services/browser-state.service"; +import { SHOW_AUTOFILL_BUTTON } from "../constants"; +import { + createAutofillPageDetailsMock, + createChromeTabMock, + createFocusedFieldDataMock, + createPageDetailMock, + createPortSpyMock, +} from "../jest/autofill-mocks"; +import { flushPromises, sendExtensionRuntimeMessage, sendPortMessage } from "../jest/testing-utils"; +import { AutofillService } from "../services/abstractions/autofill.service"; +import { + AutofillOverlayElement, + AutofillOverlayPort, + AutofillOverlayVisibility, + RedirectFocusDirection, +} from "../utils/autofill-overlay.enum"; + +import OverlayBackground from "./overlay.background"; + +const iconServerUrl = "https://icons.bitwarden.com/"; + +describe("OverlayBackground", () => { + let buttonPortSpy: chrome.runtime.Port; + let listPortSpy: chrome.runtime.Port; + let overlayBackground: OverlayBackground; + const cipherService = mock(); + const autofillService = mock(); + const authService = mock(); + const environmentService = mock({ + getIconsUrl: () => iconServerUrl, + }); + const settingsService = mock(); + const stateService = mock(); + const i18nService = mock(); + const initOverlayElementPorts = (options = { initList: true, initButton: true }) => { + const { initList, initButton } = options; + if (initButton) { + overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.Button)); + buttonPortSpy = overlayBackground["overlayButtonPort"]; + } + + if (initList) { + overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.List)); + listPortSpy = overlayBackground["overlayListPort"]; + } + + return { buttonPortSpy, listPortSpy }; + }; + + beforeEach(() => { + overlayBackground = new OverlayBackground( + cipherService, + autofillService, + authService, + environmentService, + settingsService, + stateService, + i18nService + ); + overlayBackground.init(); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockReset(cipherService); + }); + + describe("removePageDetails", () => { + it("removes the page details for a specific tab from the pageDetailsForTab object", () => { + const tabId = 1; + overlayBackground["pageDetailsForTab"][tabId] = [createPageDetailMock()]; + overlayBackground.removePageDetails(tabId); + + expect(overlayBackground["pageDetailsForTab"][tabId]).toBeUndefined(); + }); + }); + + describe("init", () => { + it("sets up the extension message listeners, get the overlay's visibility settings, and get the user's auth status", async () => { + overlayBackground["setupExtensionMessageListeners"] = jest.fn(); + overlayBackground["getOverlayVisibility"] = jest.fn(); + overlayBackground["getAuthStatus"] = jest.fn(); + + await overlayBackground.init(); + + expect(overlayBackground["setupExtensionMessageListeners"]).toHaveBeenCalled(); + expect(overlayBackground["getOverlayVisibility"]).toHaveBeenCalled(); + expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); + }); + }); + + describe("updateOverlayCiphers", () => { + const url = "https://jest-testing-website.com"; + const tab = createChromeTabMock({ url }); + const cipher1 = mock({ + id: "id-1", + localData: { lastUsedDate: 222 }, + name: "name-1", + type: CipherType.Login, + login: { username: "username-1", uri: url }, + }); + const cipher2 = mock({ + id: "id-2", + localData: { lastUsedDate: 111 }, + name: "name-2", + type: CipherType.Login, + login: { username: "username-2", uri: url }, + }); + + beforeEach(() => { + overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; + }); + + it("ignores updating the overlay ciphers if the user's auth status is not unlocked", async () => { + overlayBackground["userAuthStatus"] = AuthenticationStatus.Locked; + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); + jest.spyOn(cipherService, "getAllDecryptedForUrl"); + + await overlayBackground.updateOverlayCiphers(); + + expect(BrowserApi.getTabFromCurrentWindowId).not.toHaveBeenCalled(); + expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); + }); + + it("ignores updating the overlay ciphers if the tab is undefined", async () => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(undefined); + jest.spyOn(cipherService, "getAllDecryptedForUrl"); + + await overlayBackground.updateOverlayCiphers(); + + expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); + expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); + }); + + it("queries all ciphers for the given url, sort them by last used, and format them for usage in the overlay", async () => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + jest.spyOn(overlayBackground as any, "getOverlayCipherData"); + + await overlayBackground.updateOverlayCiphers(); + + expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled(); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url); + expect(overlayBackground["cipherService"].sortCiphersByLastUsedThenName).toHaveBeenCalled(); + expect(overlayBackground["overlayLoginCiphers"]).toStrictEqual( + new Map([ + ["overlay-cipher-0", cipher2], + ["overlay-cipher-1", cipher1], + ]) + ); + expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); + }); + + it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateIsOverlayCiphersPopulated` message to the tab indicating that the list of ciphers is populated", async () => { + overlayBackground["overlayListPort"] = mock(); + cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]); + cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1); + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(tab); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + + await overlayBackground.updateOverlayCiphers(); + + expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayListCiphers", + ciphers: [ + { + card: null, + favorite: cipher2.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-0", + login: { + username: "us*******2", + }, + name: "name-2", + reprompt: cipher2.reprompt, + type: 1, + }, + { + card: null, + favorite: cipher1.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-1", + login: { + username: "us*******1", + }, + name: "name-1", + reprompt: cipher1.reprompt, + type: 1, + }, + ], + }); + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + tab, + "updateIsOverlayCiphersPopulated", + { isOverlayCiphersPopulated: true } + ); + }); + }); + + describe("getOverlayCipherData", () => { + const url = "https://jest-testing-website.com"; + const cipher1 = mock({ + id: "id-1", + localData: { lastUsedDate: 222 }, + name: "name-1", + type: CipherType.Login, + login: { username: "username-1", uri: url }, + }); + const cipher2 = mock({ + id: "id-2", + localData: { lastUsedDate: 111 }, + name: "name-2", + type: CipherType.Login, + login: { username: "username-2", uri: url }, + }); + const cipher3 = mock({ + id: "id-3", + localData: { lastUsedDate: 333 }, + name: "name-3", + type: CipherType.Card, + card: { subTitle: "Visa, *6789" }, + }); + const cipher4 = mock({ + id: "id-4", + localData: { lastUsedDate: 444 }, + name: "name-4", + type: CipherType.Card, + card: { subTitle: "Mastercard, *1234" }, + }); + + it("formats and returns the cipher data", () => { + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-0", cipher2], + ["overlay-cipher-1", cipher1], + ["overlay-cipher-2", cipher3], + ["overlay-cipher-3", cipher4], + ]); + + const overlayCipherData = overlayBackground["getOverlayCipherData"](); + + expect(overlayCipherData).toStrictEqual([ + { + card: null, + favorite: cipher2.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-0", + login: { + username: "us*******2", + }, + name: "name-2", + reprompt: cipher2.reprompt, + type: 1, + }, + { + card: null, + favorite: cipher1.favorite, + icon: { + fallbackImage: "images/bwi-globe.png", + icon: "bwi-globe", + image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png", + imageEnabled: true, + }, + id: "overlay-cipher-1", + login: { + username: "us*******1", + }, + name: "name-1", + reprompt: cipher1.reprompt, + type: 1, + }, + { + card: "Visa, *6789", + favorite: cipher3.favorite, + icon: { + fallbackImage: "", + icon: "bwi-credit-card", + image: undefined, + imageEnabled: true, + }, + id: "overlay-cipher-2", + login: null, + name: "name-3", + reprompt: cipher3.reprompt, + type: 3, + }, + { + card: "Mastercard, *1234", + favorite: cipher4.favorite, + icon: { + fallbackImage: "", + icon: "bwi-credit-card", + image: undefined, + imageEnabled: true, + }, + id: "overlay-cipher-3", + login: null, + name: "name-4", + reprompt: cipher4.reprompt, + type: 3, + }, + ]); + }); + }); + + describe("obscureName", () => { + it("returns an empty string if the name is falsy", () => { + const name: string = undefined; + + const obscureName = overlayBackground["obscureName"](name); + + expect(obscureName).toBe(""); + }); + + it("will not attempt to obscure a username that is only a domain", () => { + const name = "@domain.com"; + + const obscureName = overlayBackground["obscureName"](name); + + expect(obscureName).toBe(name); + }); + + it("will obscure all characters of a name that is less than 5 characters expect for the first character", () => { + const name = "name@domain.com"; + + const obscureName = overlayBackground["obscureName"](name); + + expect(obscureName).toBe("n***@domain.com"); + }); + + it("will obscure all characters of a name that is greater than 4 characters by less than 6 ", () => { + const name = "name1@domain.com"; + + const obscureName = overlayBackground["obscureName"](name); + + expect(obscureName).toBe("na***@domain.com"); + }); + + it("will obscure all characters of a name that is greater than 5 characters except for the first two characters and the last character", () => { + const name = "name12@domain.com"; + + const obscureName = overlayBackground["obscureName"](name); + + expect(obscureName).toBe("na***2@domain.com"); + }); + }); + + describe("getAuthStatus", () => { + it("will update the user's auth status but will not update the overlay ciphers", async () => { + const authStatus = AuthenticationStatus.Unlocked; + overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; + jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); + jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); + jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + + const status = await overlayBackground["getAuthStatus"](); + + expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayButtonAuthStatus"]).not.toHaveBeenCalled(); + expect(overlayBackground["updateOverlayCiphers"]).not.toHaveBeenCalled(); + expect(overlayBackground["userAuthStatus"]).toBe(authStatus); + expect(status).toBe(authStatus); + }); + + it("will update the user's auth status and update the overlay ciphers if the status has been modified", async () => { + const authStatus = AuthenticationStatus.Unlocked; + overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; + jest.spyOn(overlayBackground["authService"], "getAuthStatus").mockResolvedValue(authStatus); + jest.spyOn(overlayBackground as any, "updateOverlayButtonAuthStatus").mockImplementation(); + jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + + await overlayBackground["getAuthStatus"](); + + expect(overlayBackground["authService"].getAuthStatus).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayButtonAuthStatus"]).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayCiphers"]).toHaveBeenCalled(); + expect(overlayBackground["userAuthStatus"]).toBe(authStatus); + }); + }); + + describe("updateOverlayButtonAuthStatus", () => { + it("will send a message to the button port with the user's auth status", () => { + overlayBackground["overlayButtonPort"] = mock(); + jest.spyOn(overlayBackground["overlayButtonPort"], "postMessage"); + + overlayBackground["updateOverlayButtonAuthStatus"](); + + expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayButtonAuthStatus", + authStatus: overlayBackground["userAuthStatus"], + }); + }); + }); + + describe("getTranslations", () => { + it("will query the overlay page translations if they have not been queried", () => { + overlayBackground["overlayPageTranslations"] = undefined; + jest.spyOn(overlayBackground as any, "getTranslations"); + jest.spyOn(overlayBackground["i18nService"], "translate").mockImplementation((key) => key); + jest.spyOn(BrowserApi, "getUILanguage").mockReturnValue("en"); + + const translations = overlayBackground["getTranslations"](); + + expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); + const translationKeys = [ + "opensInANewWindow", + "bitwardenOverlayButton", + "toggleBitwardenVaultOverlay", + "bitwardenVault", + "unlockYourAccountToViewMatchingLogins", + "unlockAccount", + "fillCredentialsFor", + "partialUsername", + "view", + "noItemsToShow", + "newItem", + "addNewVaultItem", + ]; + translationKeys.forEach((key) => { + expect(overlayBackground["i18nService"].translate).toHaveBeenCalledWith(key); + }); + expect(translations).toStrictEqual({ + locale: "en", + opensInANewWindow: "opensInANewWindow", + buttonPageTitle: "bitwardenOverlayButton", + toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay", + listPageTitle: "bitwardenVault", + unlockYourAccount: "unlockYourAccountToViewMatchingLogins", + unlockAccount: "unlockAccount", + fillCredentialsFor: "fillCredentialsFor", + partialUsername: "partialUsername", + view: "view", + noItemsToShow: "noItemsToShow", + newItem: "newItem", + addNewVaultItem: "addNewVaultItem", + }); + }); + }); + + describe("setupExtensionMessageListeners", () => { + it("will set up onMessage and onConnect listeners", () => { + overlayBackground["setupExtensionMessageListeners"](); + + // eslint-disable-next-line + expect(chrome.runtime.onMessage.addListener).toHaveBeenCalled(); + expect(chrome.runtime.onConnect.addListener).toHaveBeenCalled(); + }); + }); + + describe("handleExtensionMessage", () => { + it("will return early if the message command is not present within the extensionMessageHandlers", () => { + const message = { + command: "not-a-command", + }; + const sender = mock({ tab: { id: 1 } }); + const sendResponse = jest.fn(); + + const returnValue = overlayBackground["handleExtensionMessage"]( + message, + sender, + sendResponse + ); + + expect(returnValue).toBe(undefined); + expect(sendResponse).not.toHaveBeenCalled(); + }); + + it("will trigger the message handler and return undefined if the message does not have a response", () => { + const message = { + command: "autofillOverlayElementClosed", + }; + const sender = mock({ tab: { id: 1 } }); + const sendResponse = jest.fn(); + jest.spyOn(overlayBackground as any, "overlayElementClosed"); + + const returnValue = overlayBackground["handleExtensionMessage"]( + message, + sender, + sendResponse + ); + + expect(returnValue).toBe(undefined); + expect(sendResponse).not.toHaveBeenCalled(); + expect(overlayBackground["overlayElementClosed"]).toHaveBeenCalledWith(message); + }); + + it("will return a response if the message handler returns a response", async () => { + const message = { + command: "openAutofillOverlay", + }; + const sender = mock({ tab: { id: 1 } }); + const sendResponse = jest.fn(); + jest.spyOn(overlayBackground as any, "getTranslations").mockReturnValue("translations"); + + const returnValue = overlayBackground["handleExtensionMessage"]( + message, + sender, + sendResponse + ); + + expect(returnValue).toBe(true); + }); + + describe("extension message handlers", () => { + beforeEach(() => { + jest + .spyOn(overlayBackground as any, "getAuthStatus") + .mockResolvedValue(AuthenticationStatus.Unlocked); + }); + + describe("openAutofillOverlay message handler", () => { + it("opens the autofill overlay by sending a message to the current tab", async () => { + const sender = mock({ tab: { id: 1 } }); + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + + sendExtensionRuntimeMessage({ command: "openAutofillOverlay" }); + await flushPromises(); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + sender.tab, + "openAutofillOverlay", + { + isFocusingFieldElement: false, + isOpeningFullOverlay: false, + authStatus: AuthenticationStatus.Unlocked, + } + ); + }); + }); + + describe("autofillOverlayElementClosed message handler", () => { + beforeEach(() => { + initOverlayElementPorts(); + }); + + it("disconnects the button element port", () => { + sendExtensionRuntimeMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.disconnect).toHaveBeenCalled(); + expect(overlayBackground["overlayButtonPort"]).toBeNull(); + }); + + it("disconnects the list element port", () => { + sendExtensionRuntimeMessage({ + command: "autofillOverlayElementClosed", + overlayElement: AutofillOverlayElement.List, + }); + + expect(listPortSpy.disconnect).toHaveBeenCalled(); + expect(overlayBackground["overlayListPort"]).toBeNull(); + }); + }); + + describe("autofillOverlayAddNewVaultItem message handler", () => { + let sender: chrome.runtime.MessageSender; + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + jest + .spyOn(overlayBackground["stateService"], "setAddEditCipherInfo") + .mockImplementation(); + jest.spyOn(overlayBackground as any, "openAddEditVaultItemPopout").mockImplementation(); + }); + + it("will not open the add edit popout window if the message does not have a login cipher provided", () => { + sendExtensionRuntimeMessage({ command: "autofillOverlayAddNewVaultItem" }, sender); + + expect(overlayBackground["stateService"].setAddEditCipherInfo).not.toHaveBeenCalled(); + expect(overlayBackground["openAddEditVaultItemPopout"]).not.toHaveBeenCalled(); + }); + + it("will open the add edit popout window after creating a new cipher", async () => { + sendExtensionRuntimeMessage( + { + command: "autofillOverlayAddNewVaultItem", + login: { + uri: "https://tacos.com", + hostname: "", + username: "username", + password: "password", + }, + }, + sender + ); + await flushPromises(); + + expect(overlayBackground["stateService"].setAddEditCipherInfo).toHaveBeenCalled(); + expect(overlayBackground["openAddEditVaultItemPopout"]).toHaveBeenCalled(); + }); + }); + + describe("getAutofillOverlayVisibility message handler", () => { + beforeEach(() => { + jest + .spyOn(overlayBackground["settingsService"], "getAutoFillOverlayVisibility") + .mockResolvedValue(AutofillOverlayVisibility.OnFieldFocus); + }); + + it("will set the overlayVisibility property", async () => { + sendExtensionRuntimeMessage({ command: "getAutofillOverlayVisibility" }); + await flushPromises(); + + expect(overlayBackground["overlayVisibility"]).toBe( + AutofillOverlayVisibility.OnFieldFocus + ); + }); + + it("returns the overlayVisibility property", async () => { + const sendMessageSpy = jest.fn(); + + sendExtensionRuntimeMessage( + { command: "getAutofillOverlayVisibility" }, + undefined, + sendMessageSpy + ); + await flushPromises(); + + expect(sendMessageSpy).toHaveBeenCalledWith(AutofillOverlayVisibility.OnFieldFocus); + }); + }); + + describe("checkAutofillOverlayFocused message handler", () => { + beforeEach(() => { + initOverlayElementPorts(); + }); + + it("will check if the overlay list is focused if the list port is open", () => { + sendExtensionRuntimeMessage({ command: "checkAutofillOverlayFocused" }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillOverlayListFocused", + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillOverlayButtonFocused", + }); + }); + + it("will check if the overlay button is focused if the list port is not open", () => { + overlayBackground["overlayListPort"] = undefined; + + sendExtensionRuntimeMessage({ command: "checkAutofillOverlayFocused" }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "checkAutofillOverlayButtonFocused", + }); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "checkAutofillOverlayListFocused", + }); + }); + }); + + describe("focusAutofillOverlayList message handler", () => { + it("will send a `focusOverlayList` message to the overlay list port", () => { + initOverlayElementPorts({ initList: true, initButton: false }); + + sendExtensionRuntimeMessage({ command: "focusAutofillOverlayList" }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ command: "focusOverlayList" }); + }); + }); + + describe("updateAutofillOverlayPosition message handler", () => { + beforeEach(() => { + overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.List)); + listPortSpy = overlayBackground["overlayListPort"]; + overlayBackground["handlePortOnConnect"](createPortSpyMock(AutofillOverlayPort.Button)); + buttonPortSpy = overlayBackground["overlayButtonPort"]; + }); + + it("ignores updating the position if the overlay element type is not provided", () => { + sendExtensionRuntimeMessage({ command: "updateAutofillOverlayPosition" }); + + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: expect.anything(), + }); + }); + + it("updates the overlay button's position", () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendExtensionRuntimeMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendExtensionRuntimeMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "2px", left: "4px", top: "2px", width: "2px" }, + }); + }); + + it("modifies the overlay button's height for medium sized input elements", () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldRects: { top: 1, left: 2, height: 35, width: 4 }, + }); + sendExtensionRuntimeMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendExtensionRuntimeMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "20px", left: "-22px", top: "8px", width: "20px" }, + }); + }); + + it("modifies the overlay button's height for large sized input elements", () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldRects: { top: 1, left: 2, height: 50, width: 4 }, + }); + sendExtensionRuntimeMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendExtensionRuntimeMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "27px", left: "-32px", top: "13px", width: "27px" }, + }); + }); + + it("takes into account the right padding of the focused field in positioning the button if the right padding of the field is larger than the left padding", () => { + const focusedFieldData = createFocusedFieldDataMock({ + focusedFieldStyles: { paddingRight: "20px", paddingLeft: "6px" }, + }); + sendExtensionRuntimeMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + sendExtensionRuntimeMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(buttonPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { height: "2px", left: "-18px", top: "2px", width: "2px" }, + }); + }); + + it("will post a message to the overlay list facilitating an update of the list's position", () => { + const focusedFieldData = createFocusedFieldDataMock(); + sendExtensionRuntimeMessage({ command: "updateFocusedFieldData", focusedFieldData }); + + overlayBackground["updateOverlayPosition"]({ + overlayElement: AutofillOverlayElement.List, + }); + sendExtensionRuntimeMessage({ + command: "updateAutofillOverlayPosition", + overlayElement: AutofillOverlayElement.List, + }); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "updateIframePosition", + styles: { left: "2px", top: "4px", width: "4px" }, + }); + }); + }); + + describe("updateOverlayHidden", () => { + beforeEach(() => { + initOverlayElementPorts(); + }); + + it("returns early if the display value is not provided", () => { + const message = { + command: "updateAutofillOverlayHidden", + }; + + sendExtensionRuntimeMessage(message); + + expect(buttonPortSpy.postMessage).not.toHaveBeenCalledWith(message); + expect(listPortSpy.postMessage).not.toHaveBeenCalledWith(message); + }); + + it("posts a message to the overlay button and list with the display value", () => { + const message = { command: "updateAutofillOverlayHidden", display: "none" }; + + sendExtensionRuntimeMessage(message); + + expect(overlayBackground["overlayButtonPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayHidden", + styles: { + display: message.display, + }, + }); + expect(overlayBackground["overlayListPort"].postMessage).toHaveBeenCalledWith({ + command: "updateOverlayHidden", + styles: { + display: message.display, + }, + }); + }); + }); + + describe("collectPageDetailsResponse message handler", () => { + let sender: chrome.runtime.MessageSender; + const pageDetails1 = createAutofillPageDetailsMock({ + login: { username: "username1", password: "password1" }, + }); + const pageDetails2 = createAutofillPageDetailsMock({ + login: { username: "username2", password: "password2" }, + }); + + beforeEach(() => { + sender = mock({ tab: { id: 1 } }); + }); + + it("stores the page details provided by the message by the tab id of the sender", () => { + sendExtensionRuntimeMessage( + { command: "collectPageDetailsResponse", details: pageDetails1 }, + sender + ); + + expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual([ + { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }, + ]); + }); + + it("updates the page details for a tab that already has a set of page details stored ", () => { + overlayBackground["pageDetailsForTab"][sender.tab.id] = [ + { + frameId: sender.frameId, + tab: sender.tab, + details: pageDetails1, + }, + ]; + + sendExtensionRuntimeMessage( + { command: "collectPageDetailsResponse", details: pageDetails2 }, + sender + ); + + expect(overlayBackground["pageDetailsForTab"][sender.tab.id]).toStrictEqual([ + { frameId: sender.frameId, tab: sender.tab, details: pageDetails1 }, + { frameId: sender.frameId, tab: sender.tab, details: pageDetails2 }, + ]); + }); + }); + + describe("unlockCompleted message handler", () => { + let getAuthStatusSpy: jest.SpyInstance; + + beforeEach(() => { + overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; + jest.spyOn(BrowserApi, "tabSendMessageData"); + getAuthStatusSpy = jest + .spyOn(overlayBackground as any, "getAuthStatus") + .mockImplementation(() => { + overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; + return Promise.resolve(AuthenticationStatus.Unlocked); + }); + }); + + it("updates the user's auth status but does not open the overlay", async () => { + const message = { + command: "unlockCompleted", + data: { + commandToRetry: { msg: { command: "" } }, + }, + }; + + sendExtensionRuntimeMessage(message); + await flushPromises(); + + expect(getAuthStatusSpy).toHaveBeenCalled(); + expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); + }); + + it("updates user's auth status and opens the overlay if a follow up command is provided", async () => { + const sender = mock({ tab: { id: 1 } }); + const message = { + command: "unlockCompleted", + data: { + commandToRetry: { msg: { command: "openAutofillOverlay" } }, + }, + }; + jest.spyOn(BrowserApi, "getTabFromCurrentWindowId").mockResolvedValueOnce(sender.tab); + + sendExtensionRuntimeMessage(message); + await flushPromises(); + + expect(getAuthStatusSpy).toHaveBeenCalled(); + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + sender.tab, + "openAutofillOverlay", + { + isFocusingFieldElement: true, + isOpeningFullOverlay: false, + authStatus: AuthenticationStatus.Unlocked, + } + ); + }); + }); + + describe("addEditCipherSubmitted message handler", () => { + it("updates the overlay ciphers", () => { + const message = { + command: "addEditCipherSubmitted", + }; + jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + + sendExtensionRuntimeMessage(message); + + expect(overlayBackground["updateOverlayCiphers"]).toHaveBeenCalled(); + }); + }); + + describe("deletedCipher message handler", () => { + it("updates the overlay ciphers", () => { + const message = { + command: "deletedCipher", + }; + jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); + + sendExtensionRuntimeMessage(message); + + expect(overlayBackground["updateOverlayCiphers"]).toHaveBeenCalled(); + }); + }); + }); + }); + + describe("handlePortOnConnect", () => { + beforeEach(() => { + jest.spyOn(overlayBackground as any, "updateOverlayPosition").mockImplementation(); + jest.spyOn(overlayBackground as any, "getAuthStatus").mockImplementation(); + jest.spyOn(overlayBackground as any, "getTranslations").mockImplementation(); + jest.spyOn(overlayBackground as any, "getOverlayCipherData").mockImplementation(); + }); + + it("skips setting up the overlay port if the port connection is not for an overlay element", () => { + const port = createPortSpyMock("not-an-overlay-element"); + + overlayBackground["handlePortOnConnect"](port); + + expect(port.onMessage.addListener).not.toHaveBeenCalled(); + expect(port.postMessage).not.toHaveBeenCalled(); + }); + + it("sets up the overlay list port if the port connection is for the overlay list", async () => { + initOverlayElementPorts({ initList: true, initButton: false }); + await flushPromises(); + + expect(overlayBackground["overlayButtonPort"]).toBeUndefined(); + expect(listPortSpy.onMessage.addListener).toHaveBeenCalled(); + expect(listPortSpy.postMessage).toHaveBeenCalled(); + expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); + expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/list.css"); + expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); + expect(overlayBackground["getOverlayCipherData"]).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith({ + overlayElement: AutofillOverlayElement.List, + }); + }); + + it("sets up the overlay button port if the port connection is for the overlay button", async () => { + initOverlayElementPorts({ initList: false, initButton: true }); + await flushPromises(); + + expect(overlayBackground["overlayListPort"]).toBeUndefined(); + expect(buttonPortSpy.onMessage.addListener).toHaveBeenCalled(); + expect(buttonPortSpy.postMessage).toHaveBeenCalled(); + expect(overlayBackground["getAuthStatus"]).toHaveBeenCalled(); + expect(chrome.runtime.getURL).toHaveBeenCalledWith("overlay/button.css"); + expect(overlayBackground["getTranslations"]).toHaveBeenCalled(); + expect(overlayBackground["updateOverlayPosition"]).toHaveBeenCalledWith({ + overlayElement: AutofillOverlayElement.Button, + }); + }); + + it("gets the system theme", async () => { + jest.spyOn(overlayBackground["stateService"], "getTheme").mockResolvedValue(ThemeType.System); + window.matchMedia = jest.fn(() => mock({ matches: true })); + + initOverlayElementPorts({ initList: true, initButton: false }); + await flushPromises(); + + expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)"); + }); + }); + + describe("handleOverlayElementPortMessage", () => { + beforeEach(() => { + initOverlayElementPorts(); + overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; + }); + + it("ignores port messages that do not contain a handler", () => { + jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); + + sendPortMessage(buttonPortSpy, { command: "checkAutofillOverlayButtonFocused" }); + + expect(overlayBackground["checkOverlayButtonFocused"]).not.toHaveBeenCalled(); + }); + + describe("overlay button message handlers", () => { + it("unlocks the vault if the user auth status is not unlocked", () => { + overlayBackground["userAuthStatus"] = AuthenticationStatus.LoggedOut; + jest.spyOn(overlayBackground as any, "unlockVault").mockImplementation(); + + sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); + + expect(overlayBackground["unlockVault"]).toHaveBeenCalled(); + }); + + it("opens the autofill overlay if the auth status is unlocked", () => { + jest.spyOn(overlayBackground as any, "openOverlay").mockImplementation(); + + sendPortMessage(buttonPortSpy, { command: "overlayButtonClicked" }); + + expect(overlayBackground["openOverlay"]).toHaveBeenCalled(); + }); + + describe("closeAutofillOverlay", () => { + it("sends a `closeOverlay` message to the sender tab", () => { + jest.spyOn(BrowserApi, "tabSendMessage"); + + sendPortMessage(buttonPortSpy, { command: "closeAutofillOverlay" }); + + expect(BrowserApi.tabSendMessage).toHaveBeenCalledWith(buttonPortSpy.sender.tab, { + command: "closeAutofillOverlay", + }); + }); + }); + + describe("overlayPageBlurred", () => { + it("checks if the overlay list is focused", () => { + jest.spyOn(overlayBackground as any, "checkOverlayListFocused"); + + sendPortMessage(buttonPortSpy, { command: "overlayPageBlurred" }); + + expect(overlayBackground["checkOverlayListFocused"]).toHaveBeenCalled(); + }); + }); + + describe("redirectOverlayFocusOut", () => { + beforeEach(() => { + jest.spyOn(BrowserApi, "tabSendMessageData"); + }); + + it("ignores the redirect message if the direction is not provided", () => { + sendPortMessage(buttonPortSpy, { command: "redirectOverlayFocusOut" }); + + expect(BrowserApi.tabSendMessageData).not.toHaveBeenCalled(); + }); + + it("sends the redirect message if the direction is provided", () => { + sendPortMessage(buttonPortSpy, { + command: "redirectOverlayFocusOut", + direction: RedirectFocusDirection.Next, + }); + + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + buttonPortSpy.sender.tab, + "redirectOverlayFocusOut", + { direction: RedirectFocusDirection.Next } + ); + }); + }); + }); + + describe("overlay list message handlers", () => { + describe("checkAutofillOverlayButtonFocused", () => { + it("checks on the focus state of the overlay button", () => { + jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); + + sendPortMessage(listPortSpy, { command: "checkAutofillOverlayButtonFocused" }); + + expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); + }); + }); + + describe("overlayPageBlurred", () => { + it("checks on the focus state of the overlay button", () => { + jest.spyOn(overlayBackground as any, "checkOverlayButtonFocused").mockImplementation(); + + sendPortMessage(listPortSpy, { command: "overlayPageBlurred" }); + + expect(overlayBackground["checkOverlayButtonFocused"]).toHaveBeenCalled(); + }); + }); + + describe("unlockVault", () => { + it("closes the autofill overlay and opens the unlock popout", async () => { + jest.spyOn(overlayBackground as any, "closeOverlay").mockImplementation(); + jest.spyOn(overlayBackground as any, "openUnlockPopout").mockImplementation(); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + + sendPortMessage(listPortSpy, { command: "unlockVault" }); + await flushPromises(); + + expect(overlayBackground["closeOverlay"]).toHaveBeenCalledWith(listPortSpy); + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith( + listPortSpy.sender.tab, + "addToLockedVaultPendingNotifications", + { + commandToRetry: { + msg: { command: "openAutofillOverlay" }, + sender: listPortSpy.sender, + }, + target: "overlay.background", + } + ); + expect(overlayBackground["openUnlockPopout"]).toHaveBeenCalledWith( + listPortSpy.sender.tab, + true + ); + }); + }); + + describe("fillSelectedListItem", () => { + let getLoginCiphersSpy: jest.SpyInstance; + let isPasswordRepromptRequiredSpy: jest.SpyInstance; + let doAutoFillSpy: jest.SpyInstance; + + beforeEach(() => { + getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); + isPasswordRepromptRequiredSpy = jest.spyOn( + overlayBackground["autofillService"], + "isPasswordRepromptRequired" + ); + doAutoFillSpy = jest.spyOn(overlayBackground["autofillService"], "doAutoFill"); + }); + + it("ignores the fill request if the overlay cipher id is not provided", async () => { + sendPortMessage(listPortSpy, { command: "fillSelectedListItem" }); + await flushPromises(); + + expect(getLoginCiphersSpy).not.toHaveBeenCalled(); + expect(isPasswordRepromptRequiredSpy).not.toHaveBeenCalled(); + expect(doAutoFillSpy).not.toHaveBeenCalled(); + }); + + it("ignores the fill request if a master password reprompt is required", async () => { + const cipher = mock({ + reprompt: CipherRepromptType.Password, + type: CipherType.Login, + }); + overlayBackground["overlayLoginCiphers"] = new Map([["overlay-cipher-1", cipher]]); + getLoginCiphersSpy = jest.spyOn(overlayBackground["overlayLoginCiphers"], "get"); + isPasswordRepromptRequiredSpy.mockResolvedValue(true); + + sendPortMessage(listPortSpy, { + command: "fillSelectedListItem", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(getLoginCiphersSpy).toHaveBeenCalled(); + expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( + cipher, + listPortSpy.sender.tab + ); + expect(doAutoFillSpy).not.toHaveBeenCalled(); + }); + + it("auto-fills the selected cipher and move it to the top of the front of the ciphers map", async () => { + const cipher1 = mock({ id: "overlay-cipher-1" }); + const cipher2 = mock({ id: "overlay-cipher-2" }); + const cipher3 = mock({ id: "overlay-cipher-3" }); + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-1", cipher1], + ["overlay-cipher-2", cipher2], + ["overlay-cipher-3", cipher3], + ]); + isPasswordRepromptRequiredSpy.mockResolvedValue(false); + + sendPortMessage(listPortSpy, { + command: "fillSelectedListItem", + overlayCipherId: "overlay-cipher-2", + }); + await flushPromises(); + + expect(isPasswordRepromptRequiredSpy).toHaveBeenCalledWith( + cipher2, + listPortSpy.sender.tab + ); + expect(doAutoFillSpy).toHaveBeenCalledWith({ + tab: listPortSpy.sender.tab, + cipher: cipher2, + pageDetails: undefined, + fillNewPassword: true, + allowTotpAutofill: true, + }); + expect(overlayBackground["overlayLoginCiphers"].entries()).toStrictEqual( + new Map([ + ["overlay-cipher-2", cipher2], + ["overlay-cipher-1", cipher1], + ["overlay-cipher-3", cipher3], + ]).entries() + ); + }); + }); + + describe("getNewVaultItemDetails", () => { + it("will send an addNewVaultItemFromOverlay message", async () => { + jest.spyOn(BrowserApi, "tabSendMessage"); + + sendPortMessage(listPortSpy, { command: "addNewVaultItem" }); + await flushPromises(); + + expect(BrowserApi.tabSendMessage).toHaveBeenCalledWith(listPortSpy.sender.tab, { + command: "addNewVaultItemFromOverlay", + }); + }); + }); + + describe("viewSelectedCipher", () => { + let openViewVaultItemPopoutSpy: jest.SpyInstance; + + beforeEach(() => { + openViewVaultItemPopoutSpy = jest + .spyOn(overlayBackground as any, "openViewVaultItemPopout") + .mockImplementation(); + }); + + it("returns early if the passed cipher ID does not match one of the overlay login ciphers", async () => { + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], + ]); + + sendPortMessage(listPortSpy, { + command: "viewSelectedCipher", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(openViewVaultItemPopoutSpy).not.toHaveBeenCalled(); + }); + + it("will open the view vault item popout with the selected cipher", async () => { + const cipher = mock({ id: "overlay-cipher-1" }); + overlayBackground["overlayLoginCiphers"] = new Map([ + ["overlay-cipher-0", mock({ id: "overlay-cipher-0" })], + ["overlay-cipher-1", cipher], + ]); + + sendPortMessage(listPortSpy, { + command: "viewSelectedCipher", + overlayCipherId: "overlay-cipher-1", + }); + await flushPromises(); + + expect(overlayBackground["openViewVaultItemPopout"]).toHaveBeenCalledWith( + listPortSpy.sender.tab, + { + cipherId: cipher.id, + action: SHOW_AUTOFILL_BUTTON, + } + ); + }); + }); + + describe("redirectOverlayFocusOut", () => { + it("redirects focus out of the overlay list", async () => { + const message = { + command: "redirectOverlayFocusOut", + direction: RedirectFocusDirection.Next, + }; + const redirectOverlayFocusOutSpy = jest.spyOn( + overlayBackground as any, + "redirectOverlayFocusOut" + ); + + sendPortMessage(listPortSpy, message); + await flushPromises(); + + expect(redirectOverlayFocusOutSpy).toHaveBeenCalledWith(message, listPortSpy); + }); + }); + }); + }); +}); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts new file mode 100644 index 00000000000..b80974eda2e --- /dev/null +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -0,0 +1,766 @@ +import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ThemeType } from "@bitwarden/common/enums"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; + +import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; +import LockedVaultPendingNotificationsItem from "../../background/models/lockedVaultPendingNotificationsItem"; +import { BrowserApi } from "../../platform/browser/browser-api"; +import { + openViewVaultItemPopout, + openAddEditVaultItemPopout, +} from "../../vault/popup/utils/vault-popout-window"; +import { SHOW_AUTOFILL_BUTTON } from "../constants"; +import { AutofillService, PageDetail } from "../services/abstractions/autofill.service"; +import { AutofillOverlayElement, AutofillOverlayPort } from "../utils/autofill-overlay.enum"; + +import { + FocusedFieldData, + OverlayBackgroundExtensionMessageHandlers, + OverlayButtonPortMessageHandlers, + OverlayCipherData, + OverlayListPortMessageHandlers, + OverlayBackground as OverlayBackgroundInterface, + OverlayBackgroundExtensionMessage, + OverlayAddNewItemMessage, + OverlayPortMessage, + WebsiteIconData, +} from "./abstractions/overlay.background"; + +class OverlayBackground implements OverlayBackgroundInterface { + private readonly openUnlockPopout = openUnlockPopout; + private readonly openViewVaultItemPopout = openViewVaultItemPopout; + private readonly openAddEditVaultItemPopout = openAddEditVaultItemPopout; + private overlayVisibility: number; + private overlayLoginCiphers: Map = new Map(); + private pageDetailsForTab: Record = {}; + private userAuthStatus: AuthenticationStatus = AuthenticationStatus.LoggedOut; + private overlayButtonPort: chrome.runtime.Port; + private overlayListPort: chrome.runtime.Port; + private focusedFieldData: FocusedFieldData; + private overlayPageTranslations: Record; + private readonly iconsServerUrl: string; + private readonly extensionMessageHandlers: OverlayBackgroundExtensionMessageHandlers = { + openAutofillOverlay: () => this.openOverlay(false), + autofillOverlayElementClosed: ({ message }) => this.overlayElementClosed(message), + autofillOverlayAddNewVaultItem: ({ message, sender }) => this.addNewVaultItem(message, sender), + getAutofillOverlayVisibility: () => this.getOverlayVisibility(), + checkAutofillOverlayFocused: () => this.checkOverlayFocused(), + focusAutofillOverlayList: () => this.focusOverlayList(), + updateAutofillOverlayPosition: ({ message }) => this.updateOverlayPosition(message), + updateAutofillOverlayHidden: ({ message }) => this.updateOverlayHidden(message), + updateFocusedFieldData: ({ message }) => this.setFocusedFieldData(message), + collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender), + unlockCompleted: ({ message }) => this.unlockCompleted(message), + addEditCipherSubmitted: () => this.updateOverlayCiphers(), + deletedCipher: () => this.updateOverlayCiphers(), + }; + private readonly overlayButtonPortMessageHandlers: OverlayButtonPortMessageHandlers = { + overlayButtonClicked: ({ port }) => this.handleOverlayButtonClicked(port), + closeAutofillOverlay: ({ port }) => this.closeOverlay(port), + overlayPageBlurred: () => this.checkOverlayListFocused(), + redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port), + }; + private readonly overlayListPortMessageHandlers: OverlayListPortMessageHandlers = { + checkAutofillOverlayButtonFocused: () => this.checkOverlayButtonFocused(), + overlayPageBlurred: () => this.checkOverlayButtonFocused(), + unlockVault: ({ port }) => this.unlockVault(port), + fillSelectedListItem: ({ message, port }) => this.fillSelectedOverlayListItem(message, port), + addNewVaultItem: ({ port }) => this.getNewVaultItemDetails(port), + viewSelectedCipher: ({ message, port }) => this.viewSelectedCipher(message, port), + redirectOverlayFocusOut: ({ message, port }) => this.redirectOverlayFocusOut(message, port), + }; + + constructor( + private cipherService: CipherService, + private autofillService: AutofillService, + private authService: AuthService, + private environmentService: EnvironmentService, + private settingsService: SettingsService, + private stateService: StateService, + private i18nService: I18nService + ) { + this.iconsServerUrl = this.environmentService.getIconsUrl(); + } + + /** + * Removes cached page details for a tab + * based on the passed tabId. + * + * @param tabId - Used to reference the page details of a specific tab + */ + removePageDetails(tabId: number) { + delete this.pageDetailsForTab[tabId]; + } + + /** + * Sets up the extension message listeners and gets the settings for the + * overlay's visibility and the user's authentication status. + */ + async init() { + this.setupExtensionMessageListeners(); + await this.getOverlayVisibility(); + await this.getAuthStatus(); + } + + /** + * Updates the overlay list's ciphers and sends the updated list to the overlay list iframe. + * Queries all ciphers for the given url, and sorts them by last used. Will not update the + * list of ciphers if the extension is not unlocked. + */ + async updateOverlayCiphers() { + if (this.userAuthStatus !== AuthenticationStatus.Unlocked) { + return; + } + + const currentTab = await BrowserApi.getTabFromCurrentWindowId(); + if (!currentTab?.url) { + return; + } + + this.overlayLoginCiphers = new Map(); + const ciphersViews = (await this.cipherService.getAllDecryptedForUrl(currentTab.url)).sort( + (a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b) + ); + for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) { + this.overlayLoginCiphers.set(`overlay-cipher-${cipherIndex}`, ciphersViews[cipherIndex]); + } + + const ciphers = this.getOverlayCipherData(); + this.overlayListPort?.postMessage({ command: "updateOverlayListCiphers", ciphers }); + await BrowserApi.tabSendMessageData(currentTab, "updateIsOverlayCiphersPopulated", { + isOverlayCiphersPopulated: Boolean(ciphers.length), + }); + } + + /** + * Strips out unnecessary data from the ciphers and returns an array of + * objects that contain the cipher data needed for the overlay list. + */ + private getOverlayCipherData(): OverlayCipherData[] { + const isFaviconDisabled = this.settingsService.getDisableFavicon(); + const overlayCiphersArray = Array.from(this.overlayLoginCiphers); + const overlayCipherData = []; + let loginCipherIcon: WebsiteIconData; + + for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) { + const [overlayCipherId, cipher] = overlayCiphersArray[cipherIndex]; + if (!loginCipherIcon && cipher.type === CipherType.Login) { + loginCipherIcon = buildCipherIcon(this.iconsServerUrl, cipher, isFaviconDisabled); + } + + overlayCipherData.push({ + id: overlayCipherId, + name: cipher.name, + type: cipher.type, + reprompt: cipher.reprompt, + favorite: cipher.favorite, + icon: + cipher.type === CipherType.Login + ? loginCipherIcon + : buildCipherIcon(this.iconsServerUrl, cipher, isFaviconDisabled), + login: + cipher.type === CipherType.Login + ? { username: this.obscureName(cipher.login.username) } + : null, + card: cipher.type === CipherType.Card ? cipher.card.subTitle : null, + }); + } + + return overlayCipherData; + } + + /** + * Handles aggregation of page details for a tab. Stores the page details + * in association with the tabId of the tab that sent the message. + * + * @param message - Message received from the `collectPageDetailsResponse` command + * @param sender - The sender of the message + */ + private storePageDetails( + message: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender + ) { + const pageDetails = { + frameId: sender.frameId, + tab: sender.tab, + details: message.details, + }; + + if (this.pageDetailsForTab[sender.tab.id]?.length) { + this.pageDetailsForTab[sender.tab.id].push(pageDetails); + return; + } + + this.pageDetailsForTab[sender.tab.id] = [pageDetails]; + } + + /** + * Triggers autofill for the selected cipher in the overlay list. Also places + * the selected cipher at the top of the list of ciphers. + * + * @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID. + * @param sender - The sender of the port message + */ + private async fillSelectedOverlayListItem( + { overlayCipherId }: OverlayPortMessage, + { sender }: chrome.runtime.Port + ) { + if (!overlayCipherId) { + return; + } + + const cipher = this.overlayLoginCiphers.get(overlayCipherId); + + if (await this.autofillService.isPasswordRepromptRequired(cipher, sender.tab)) { + return; + } + await this.autofillService.doAutoFill({ + tab: sender.tab, + cipher: cipher, + pageDetails: this.pageDetailsForTab[sender.tab.id], + fillNewPassword: true, + allowTotpAutofill: true, + }); + + this.overlayLoginCiphers = new Map([[overlayCipherId, cipher], ...this.overlayLoginCiphers]); + } + + /** + * Checks if the overlay is focused. Will check the overlay list + * if it is open, otherwise it will check the overlay button. + */ + private checkOverlayFocused() { + if (this.overlayListPort) { + this.checkOverlayListFocused(); + + return; + } + + this.checkOverlayButtonFocused(); + } + + /** + * Posts a message to the overlay button iframe to check if it is focused. + */ + private checkOverlayButtonFocused() { + this.overlayButtonPort?.postMessage({ command: "checkAutofillOverlayButtonFocused" }); + } + + /** + * Posts a message to the overlay list iframe to check if it is focused. + */ + private checkOverlayListFocused() { + this.overlayListPort?.postMessage({ command: "checkAutofillOverlayListFocused" }); + } + + /** + * Sends a message to the sender tab to close the autofill overlay. + * + * @param sender - The sender of the port message + */ + private closeOverlay({ sender }: chrome.runtime.Port) { + BrowserApi.tabSendMessage(sender.tab, { command: "closeAutofillOverlay" }); + } + + /** + * Handles cleanup when an overlay element is closed. Disconnects + * the list and button ports and sets them to null. + * + * @param overlayElement - The overlay element that was closed, either the list or button + */ + private overlayElementClosed({ overlayElement }: OverlayBackgroundExtensionMessage) { + if (overlayElement === AutofillOverlayElement.Button) { + this.overlayButtonPort?.disconnect(); + this.overlayButtonPort = null; + + return; + } + + this.overlayListPort?.disconnect(); + this.overlayListPort = null; + } + + /** + * Updates the position of either the overlay list or button. The position + * is based on the focused field's position and dimensions. + * + * @param overlayElement - The overlay element to update, either the list or button + */ + private updateOverlayPosition({ overlayElement }: { overlayElement?: string }) { + if (!overlayElement) { + return; + } + + if (overlayElement === AutofillOverlayElement.Button) { + this.overlayButtonPort?.postMessage({ + command: "updateIframePosition", + styles: this.getOverlayButtonPosition(), + }); + + return; + } + + this.overlayListPort?.postMessage({ + command: "updateIframePosition", + styles: this.getOverlayListPosition(), + }); + } + + /** + * Gets the position of the focused field and calculates the position + * of the overlay button based on the focused field's position and dimensions. + */ + private getOverlayButtonPosition() { + if (!this.focusedFieldData) { + return; + } + + const { top, left, width, height } = this.focusedFieldData.focusedFieldRects; + const { paddingRight, paddingLeft } = this.focusedFieldData.focusedFieldStyles; + let elementOffset = height * 0.37; + if (height >= 35) { + elementOffset = height >= 50 ? height * 0.47 : height * 0.42; + } + + const elementHeight = height - elementOffset; + const elementTopPosition = top + elementOffset / 2; + let elementLeftPosition = left + width - height + elementOffset / 2; + + const fieldPaddingRight = parseInt(paddingRight, 10); + const fieldPaddingLeft = parseInt(paddingLeft, 10); + if (fieldPaddingRight > fieldPaddingLeft) { + elementLeftPosition = left + width - height - (fieldPaddingRight - elementOffset + 2); + } + + return { + top: `${Math.round(elementTopPosition)}px`, + left: `${Math.round(elementLeftPosition)}px`, + height: `${Math.round(elementHeight)}px`, + width: `${Math.round(elementHeight)}px`, + }; + } + + /** + * Gets the position of the focused field and calculates the position + * of the overlay list based on the focused field's position and dimensions. + */ + private getOverlayListPosition() { + if (!this.focusedFieldData) { + return; + } + + const { top, left, width, height } = this.focusedFieldData.focusedFieldRects; + return { + width: `${Math.round(width)}px`, + top: `${Math.round(top + height)}px`, + left: `${Math.round(left)}px`, + }; + } + + /** + * Sets the focused field data to the data passed in the extension message. + * + * @param focusedFieldData - Contains the rects and styles of the focused field. + */ + private setFocusedFieldData({ focusedFieldData }: OverlayBackgroundExtensionMessage) { + this.focusedFieldData = focusedFieldData; + } + + /** + * Updates the overlay's visibility based on the display property passed in the extension message. + * + * @param display - The display property of the overlay, either "block" or "none" + */ + private updateOverlayHidden({ display }: OverlayBackgroundExtensionMessage) { + if (!display) { + return; + } + + const portMessage = { command: "updateOverlayHidden", styles: { display } }; + + this.overlayButtonPort?.postMessage(portMessage); + this.overlayListPort?.postMessage(portMessage); + } + + /** + * Sends a message to the currently active tab to open the autofill overlay. + * + * @param isFocusingFieldElement - Identifies whether the field element should be focused when the overlay is opened + * @param isOpeningFullOverlay - Identifies whether the full overlay should be forced open regardless of other states + */ + private async openOverlay(isFocusingFieldElement = false, isOpeningFullOverlay = false) { + const currentTab = await BrowserApi.getTabFromCurrentWindowId(); + + await BrowserApi.tabSendMessageData(currentTab, "openAutofillOverlay", { + isFocusingFieldElement, + isOpeningFullOverlay, + authStatus: await this.getAuthStatus(), + }); + } + + /** + * Obscures the username by replacing all but the first and last characters with asterisks. + * If the username is less than 4 characters, only the first character will be shown. + * If the username is 6 or more characters, the first and last characters will be shown. + * The domain will not be obscured. + * + * @param name - The username to obscure + */ + private obscureName(name: string): string { + if (!name) { + return ""; + } + + const [username, domain] = name.split("@"); + const usernameLength = username?.length; + if (!usernameLength) { + return name; + } + + const startingCharacters = username.slice(0, usernameLength > 4 ? 2 : 1); + let numberStars = usernameLength; + if (usernameLength > 4) { + numberStars = usernameLength < 6 ? numberStars - 1 : numberStars - 2; + } + + let obscureName = `${startingCharacters}${new Array(numberStars).join("*")}`; + if (usernameLength >= 6) { + obscureName = `${obscureName}${username.slice(-1)}`; + } + + return domain ? `${obscureName}@${domain}` : obscureName; + } + + /** + * Gets the overlay's visibility setting from the settings service. + */ + private async getOverlayVisibility(): Promise { + this.overlayVisibility = await this.settingsService.getAutoFillOverlayVisibility(); + + return this.overlayVisibility; + } + + /** + * Gets the user's authentication status from the auth service. If the user's + * authentication status has changed, the overlay button's authentication status + * will be updated and the overlay list's ciphers will be updated. + */ + private async getAuthStatus() { + const formerAuthStatus = this.userAuthStatus; + this.userAuthStatus = await this.authService.getAuthStatus(); + + if ( + this.userAuthStatus !== formerAuthStatus && + this.userAuthStatus === AuthenticationStatus.Unlocked + ) { + this.updateOverlayButtonAuthStatus(); + await this.updateOverlayCiphers(); + } + + return this.userAuthStatus; + } + + /** + * Gets the currently set theme for the user. + */ + private async getCurrentTheme() { + const theme = await this.stateService.getTheme(); + + if (theme !== ThemeType.System) { + return theme; + } + + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? ThemeType.Dark + : ThemeType.Light; + } + + /** + * Sends a message to the overlay button to update its authentication status. + */ + private updateOverlayButtonAuthStatus() { + this.overlayButtonPort?.postMessage({ + command: "updateOverlayButtonAuthStatus", + authStatus: this.userAuthStatus, + }); + } + + /** + * Handles the overlay button being clicked. If the user is not authenticated, + * the vault will be unlocked. If the user is authenticated, the overlay will + * be opened. + * + * @param port - The port of the overlay button + */ + private handleOverlayButtonClicked(port: chrome.runtime.Port) { + if (this.userAuthStatus !== AuthenticationStatus.Unlocked) { + this.unlockVault(port); + return; + } + + this.openOverlay(false, true); + } + + /** + * Facilitates opening the unlock popout window. + * + * @param port - The port of the overlay list + */ + private async unlockVault(port: chrome.runtime.Port) { + const { sender } = port; + + this.closeOverlay(port); + const retryMessage: LockedVaultPendingNotificationsItem = { + commandToRetry: { msg: { command: "openAutofillOverlay" }, sender }, + target: "overlay.background", + }; + await BrowserApi.tabSendMessageData( + sender.tab, + "addToLockedVaultPendingNotifications", + retryMessage + ); + await this.openUnlockPopout(sender.tab, true); + } + + /** + * Triggers the opening of a vault item popout window associated + * with the passed cipher ID. + * @param overlayCipherId - Cipher ID corresponding to the overlayLoginCiphers map. Does not correspond to the actual cipher's ID. + * @param sender - The sender of the port message + */ + private async viewSelectedCipher( + { overlayCipherId }: OverlayPortMessage, + { sender }: chrome.runtime.Port + ) { + const cipher = this.overlayLoginCiphers.get(overlayCipherId); + if (!cipher) { + return; + } + + await this.openViewVaultItemPopout(sender.tab, { + cipherId: cipher.id, + action: SHOW_AUTOFILL_BUTTON, + }); + } + + /** + * Facilitates redirecting focus to the overlay list. + */ + private focusOverlayList() { + this.overlayListPort?.postMessage({ command: "focusOverlayList" }); + } + + /** + * Updates the authentication status for the user and opens the overlay if + * a followup command is present in the message. + * + * @param message - Extension message received from the `unlockCompleted` command + */ + private async unlockCompleted(message: OverlayBackgroundExtensionMessage) { + await this.getAuthStatus(); + + if (message.data?.commandToRetry?.msg?.command === "openAutofillOverlay") { + await this.openOverlay(true); + } + } + + /** + * Gets the translations for the overlay page. + */ + private getTranslations() { + if (!this.overlayPageTranslations) { + this.overlayPageTranslations = { + locale: BrowserApi.getUILanguage(), + opensInANewWindow: this.i18nService.translate("opensInANewWindow"), + buttonPageTitle: this.i18nService.translate("bitwardenOverlayButton"), + toggleBitwardenVaultOverlay: this.i18nService.translate("toggleBitwardenVaultOverlay"), + listPageTitle: this.i18nService.translate("bitwardenVault"), + unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewMatchingLogins"), + unlockAccount: this.i18nService.translate("unlockAccount"), + fillCredentialsFor: this.i18nService.translate("fillCredentialsFor"), + partialUsername: this.i18nService.translate("partialUsername"), + view: this.i18nService.translate("view"), + noItemsToShow: this.i18nService.translate("noItemsToShow"), + newItem: this.i18nService.translate("newItem"), + addNewVaultItem: this.i18nService.translate("addNewVaultItem"), + }; + } + + return this.overlayPageTranslations; + } + + /** + * Facilitates redirecting focus out of one of the + * overlay elements to elements on the page. + * + * @param direction - The direction to redirect focus to (either "next", "previous" or "current) + * @param sender - The sender of the port message + */ + private redirectOverlayFocusOut( + { direction }: OverlayPortMessage, + { sender }: chrome.runtime.Port + ) { + if (!direction) { + return; + } + + BrowserApi.tabSendMessageData(sender.tab, "redirectOverlayFocusOut", { direction }); + } + + /** + * Triggers adding a new vault item from the overlay. Gathers data + * input by the user before calling to open the add/edit window. + * + * @param sender - The sender of the port message + */ + private getNewVaultItemDetails({ sender }: chrome.runtime.Port) { + BrowserApi.tabSendMessage(sender.tab, { command: "addNewVaultItemFromOverlay" }); + } + + /** + * Handles adding a new vault item from the overlay. Gathers data login + * data captured in the extension message. + * + * @param login - The login data captured from the extension message + * @param sender - The sender of the extension message + */ + private async addNewVaultItem( + { login }: OverlayAddNewItemMessage, + sender: chrome.runtime.MessageSender + ) { + if (!login) { + return; + } + + const uriView = new LoginUriView(); + uriView.uri = login.uri; + + const loginView = new LoginView(); + loginView.uris = [uriView]; + loginView.username = login.username || ""; + loginView.password = login.password || ""; + + const cipherView = new CipherView(); + cipherView.name = (Utils.getHostname(login.uri) || login.hostname).replace(/^www\./, ""); + cipherView.folderId = null; + cipherView.type = CipherType.Login; + cipherView.login = loginView; + + await this.stateService.setAddEditCipherInfo({ + cipher: cipherView, + collectionIds: cipherView.collectionIds, + }); + + await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id }); + } + + /** + * Sets up the extension message listeners for the overlay. + */ + private setupExtensionMessageListeners() { + BrowserApi.messageListener("overlay.background", this.handleExtensionMessage); + chrome.runtime.onConnect.addListener(this.handlePortOnConnect); + } + + /** + * Handles extension messages sent to the extension background. + * + * @param message - The message received from the extension + * @param sender - The sender of the message + * @param sendResponse - The response to send back to the sender + */ + private handleExtensionMessage = ( + message: OverlayBackgroundExtensionMessage, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void + ) => { + const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command]; + if (!handler) { + return; + } + + const messageResponse = handler({ message, sender }); + if (!messageResponse) { + return; + } + + Promise.resolve(messageResponse).then((response) => sendResponse(response)); + return true; + }; + + /** + * Handles the connection of a port to the extension background. + * + * @param port - The port that connected to the extension background + */ + private handlePortOnConnect = async (port: chrome.runtime.Port) => { + const isOverlayListPort = port.name === AutofillOverlayPort.List; + const isOverlayButtonPort = port.name === AutofillOverlayPort.Button; + + if (!isOverlayListPort && !isOverlayButtonPort) { + return; + } + + if (isOverlayListPort) { + this.overlayListPort = port; + } else { + this.overlayButtonPort = port; + } + + port.onMessage.addListener(this.handleOverlayElementPortMessage); + port.postMessage({ + command: `initAutofillOverlay${isOverlayListPort ? "List" : "Button"}`, + authStatus: await this.getAuthStatus(), + styleSheetUrl: chrome.runtime.getURL(`overlay/${isOverlayListPort ? "list" : "button"}.css`), + theme: `theme_${await this.getCurrentTheme()}`, + translations: this.getTranslations(), + ciphers: isOverlayListPort ? this.getOverlayCipherData() : null, + }); + this.updateOverlayPosition({ + overlayElement: isOverlayListPort + ? AutofillOverlayElement.List + : AutofillOverlayElement.Button, + }); + }; + + /** + * Handles messages sent to the overlay list or button ports. + * + * @param message - The message received from the port + * @param port - The port that sent the message + */ + private handleOverlayElementPortMessage = ( + message: OverlayBackgroundExtensionMessage, + port: chrome.runtime.Port + ) => { + const command = message?.command; + let handler: CallableFunction | undefined; + + if (port.name === AutofillOverlayPort.Button) { + handler = this.overlayButtonPortMessageHandlers[command]; + } + + if (port.name === AutofillOverlayPort.List) { + handler = this.overlayListPortMessageHandlers[command]; + } + + if (!handler) { + return; + } + + handler({ message, port }); + }; +} + +export default OverlayBackground; diff --git a/apps/browser/src/autofill/background/tabs.background.spec.ts b/apps/browser/src/autofill/background/tabs.background.spec.ts new file mode 100644 index 00000000000..e320f22248e --- /dev/null +++ b/apps/browser/src/autofill/background/tabs.background.spec.ts @@ -0,0 +1,238 @@ +import { mock } from "jest-mock-extended"; + +import MainBackground from "../../background/main.background"; +import { + flushPromises, + triggerTabOnActivatedEvent, + triggerTabOnRemovedEvent, + triggerTabOnReplacedEvent, + triggerTabOnUpdatedEvent, + triggerWindowOnFocusedChangedEvent, +} from "../jest/testing-utils"; + +import NotificationBackground from "./notification.background"; +import OverlayBackground from "./overlay.background"; +import TabsBackground from "./tabs.background"; + +describe("TabsBackground", () => { + let tabsBackgorund: TabsBackground; + const mainBackground = mock({ + messagingService: { + send: jest.fn(), + }, + }); + const notificationBackground = mock(); + const overlayBackground = mock(); + + beforeEach(() => { + tabsBackgorund = new TabsBackground(mainBackground, notificationBackground, overlayBackground); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("init", () => { + it("sets up a window on focusChanged listener", () => { + const handleWindowOnFocusChangedSpy = jest.spyOn( + tabsBackgorund as any, + "handleWindowOnFocusChanged" + ); + + tabsBackgorund.init(); + + expect(chrome.windows.onFocusChanged.addListener).toHaveBeenCalledWith( + handleWindowOnFocusChangedSpy + ); + }); + }); + + describe("tab event listeners", () => { + beforeEach(() => { + tabsBackgorund.init(); + }); + + describe("window onFocusChanged event", () => { + it("ignores focus change events that do not contain a windowId", async () => { + triggerWindowOnFocusedChangedEvent(undefined); + await flushPromises(); + + expect(mainBackground.messagingService.send).not.toHaveBeenCalled(); + }); + + it("sets the local focusedWindowId property", async () => { + triggerWindowOnFocusedChangedEvent(10); + await flushPromises(); + + expect(tabsBackgorund["focusedWindowId"]).toBe(10); + }); + + it("updates the current tab data", async () => { + triggerWindowOnFocusedChangedEvent(10); + await flushPromises(); + + expect(mainBackground.refreshBadge).toHaveBeenCalled(); + expect(mainBackground.refreshMenu).toHaveBeenCalled(); + expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); + }); + + it("sends a `windowChanged` message", async () => { + triggerWindowOnFocusedChangedEvent(10); + await flushPromises(); + + expect(mainBackground.messagingService.send).toHaveBeenCalledWith("windowChanged"); + }); + }); + + describe("handleTabOnActivated", () => { + it("updates the current tab data", async () => { + triggerTabOnActivatedEvent({ tabId: 10, windowId: 20 }); + await flushPromises(); + + expect(mainBackground.refreshBadge).toHaveBeenCalled(); + expect(mainBackground.refreshMenu).toHaveBeenCalled(); + expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); + }); + + it("sends a `tabChanged` message to the messaging service", async () => { + triggerTabOnActivatedEvent({ tabId: 10, windowId: 20 }); + await flushPromises(); + + expect(mainBackground.messagingService.send).toHaveBeenCalledWith("tabChanged"); + }); + }); + + describe("handleTabOnReplaced", () => { + beforeEach(() => { + mainBackground.onReplacedRan = false; + }); + + it("ignores the event if the `onReplacedRan` property of the main background class is set to `true`", () => { + mainBackground.onReplacedRan = true; + + triggerTabOnReplacedEvent(10, 20); + + expect(notificationBackground.checkNotificationQueue).not.toHaveBeenCalled(); + }); + + it("checks the notification queue", () => { + triggerTabOnReplacedEvent(10, 20); + + expect(notificationBackground.checkNotificationQueue).toHaveBeenCalled(); + }); + + it("updates the current tab data", async () => { + triggerTabOnReplacedEvent(10, 20); + await flushPromises(); + + expect(mainBackground.refreshBadge).toHaveBeenCalled(); + expect(mainBackground.refreshMenu).toHaveBeenCalled(); + expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); + }); + + it("sends a `tabChanged` message to the messaging service", async () => { + triggerTabOnReplacedEvent(10, 20); + await flushPromises(); + + expect(mainBackground.messagingService.send).toHaveBeenCalledWith("tabChanged"); + }); + }); + + describe("handleTabOnUpdated", () => { + const focusedWindowId = 10; + let tab: chrome.tabs.Tab; + + beforeEach(() => { + mainBackground.onUpdatedRan = false; + tabsBackgorund["focusedWindowId"] = focusedWindowId; + tab = mock({ + windowId: focusedWindowId, + active: true, + status: "loading", + }); + }); + + it("removes the cached page details from the overlay background if the tab status is `loading`", () => { + triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); + + expect(overlayBackground.removePageDetails).toHaveBeenCalledWith(focusedWindowId); + }); + + it("removes the cached page details from the overlay background if the tab status is `unloaded`", () => { + triggerTabOnUpdatedEvent(focusedWindowId, { status: "unloaded" }, tab); + + expect(overlayBackground.removePageDetails).toHaveBeenCalledWith(focusedWindowId); + }); + + it("skips updating the current tab data the focusedWindowId is set to a value less than zero", async () => { + tab.windowId = -1; + triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); + await flushPromises(); + + expect(mainBackground.refreshBadge).not.toHaveBeenCalled(); + expect(mainBackground.refreshMenu).not.toHaveBeenCalled(); + expect(overlayBackground.updateOverlayCiphers).not.toHaveBeenCalled(); + }); + + it("skips updating the current tab data if the updated tab is not for the focusedWindowId", async () => { + tab.windowId = 20; + triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); + await flushPromises(); + + expect(mainBackground.refreshBadge).not.toHaveBeenCalled(); + expect(mainBackground.refreshMenu).not.toHaveBeenCalled(); + expect(overlayBackground.updateOverlayCiphers).not.toHaveBeenCalled(); + }); + + it("skips updating the current tab data if the updated tab is not active", async () => { + tab.active = false; + triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); + await flushPromises(); + + expect(mainBackground.refreshBadge).not.toHaveBeenCalled(); + expect(mainBackground.refreshMenu).not.toHaveBeenCalled(); + expect(overlayBackground.updateOverlayCiphers).not.toHaveBeenCalled(); + }); + + it("skips updating the badge, context menu and notification bar if the `onUpdatedRan` property of the main background class is set to `true`", async () => { + mainBackground.onUpdatedRan = true; + triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); + await flushPromises(); + + expect(mainBackground.refreshBadge).not.toHaveBeenCalled(); + expect(mainBackground.refreshMenu).not.toHaveBeenCalled(); + }); + + it("checks the notification queue", async () => { + triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); + await flushPromises(); + + expect(notificationBackground.checkNotificationQueue).toHaveBeenCalled(); + }); + + it("updates the current tab data", async () => { + triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); + await flushPromises(); + + expect(mainBackground.refreshBadge).toHaveBeenCalled(); + expect(mainBackground.refreshMenu).toHaveBeenCalled(); + expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); + }); + + it("sends a `tabChanged` message to the messaging service", async () => { + triggerTabOnUpdatedEvent(focusedWindowId, { status: "loading" }, tab); + await flushPromises(); + + expect(mainBackground.messagingService.send).toHaveBeenCalledWith("tabChanged"); + }); + }); + + describe("handleTabOnRemoved", () => { + it("removes the cached overlay page details", () => { + triggerTabOnRemovedEvent(10, { windowId: 20, isWindowClosing: false }); + + expect(overlayBackground.removePageDetails).toHaveBeenCalledWith(10); + }); + }); + }); +}); diff --git a/apps/browser/src/autofill/background/tabs.background.ts b/apps/browser/src/autofill/background/tabs.background.ts index 0655fd23b62..c47ef76f665 100644 --- a/apps/browser/src/autofill/background/tabs.background.ts +++ b/apps/browser/src/autofill/background/tabs.background.ts @@ -1,69 +1,123 @@ import MainBackground from "../../background/main.background"; import NotificationBackground from "./notification.background"; +import OverlayBackground from "./overlay.background"; export default class TabsBackground { constructor( private main: MainBackground, - private notificationBackground: NotificationBackground + private notificationBackground: NotificationBackground, + private overlayBackground: OverlayBackground ) {} private focusedWindowId: number; + /** + * Initializes the window and tab listeners. + */ async init() { if (!chrome.tabs || !chrome.windows) { return; } - chrome.windows.onFocusChanged.addListener(async (windowId: number) => { - if (windowId === null || windowId < 0) { - return; - } - - this.focusedWindowId = windowId; - await this.main.refreshBadge(); - await this.main.refreshMenu(); - this.main.messagingService.send("windowChanged"); - }); - - chrome.tabs.onActivated.addListener(async (activeInfo: chrome.tabs.TabActiveInfo) => { - await this.main.refreshBadge(); - await this.main.refreshMenu(); - this.main.messagingService.send("tabChanged"); - }); - - chrome.tabs.onReplaced.addListener(async (addedTabId: number, removedTabId: number) => { - if (this.main.onReplacedRan) { - return; - } - this.main.onReplacedRan = true; - - await this.notificationBackground.checkNotificationQueue(); - await this.main.refreshBadge(); - await this.main.refreshMenu(); - this.main.messagingService.send("tabChanged"); - }); - - chrome.tabs.onUpdated.addListener( - async (tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => { - if (this.focusedWindowId > 0 && tab.windowId != this.focusedWindowId) { - return; - } - - if (!tab.active) { - return; - } - - if (this.main.onUpdatedRan) { - return; - } - this.main.onUpdatedRan = true; - - await this.notificationBackground.checkNotificationQueue(tab); - await this.main.refreshBadge(); - await this.main.refreshMenu(); - this.main.messagingService.send("tabChanged"); - } - ); + chrome.windows.onFocusChanged.addListener(this.handleWindowOnFocusChanged); + chrome.tabs.onActivated.addListener(this.handleTabOnActivated); + chrome.tabs.onReplaced.addListener(this.handleTabOnReplaced); + chrome.tabs.onUpdated.addListener(this.handleTabOnUpdated); + chrome.tabs.onRemoved.addListener(this.handleTabOnRemoved); } + + /** + * Handles the window onFocusChanged event. + * + * @param windowId - The ID of the window that was focused. + */ + private handleWindowOnFocusChanged = async (windowId: number) => { + if (!windowId) { + return; + } + + this.focusedWindowId = windowId; + await this.updateCurrentTabData(); + this.main.messagingService.send("windowChanged"); + }; + + /** + * Handles the tab onActivated event. + */ + private handleTabOnActivated = async () => { + await this.updateCurrentTabData(); + this.main.messagingService.send("tabChanged"); + }; + + /** + * Handles the tab onReplaced event. + */ + private handleTabOnReplaced = async () => { + if (this.main.onReplacedRan) { + return; + } + this.main.onReplacedRan = true; + + await this.notificationBackground.checkNotificationQueue(); + await this.updateCurrentTabData(); + this.main.messagingService.send("tabChanged"); + }; + + /** + * Handles the tab onUpdated event. + * + * @param tabId - The ID of the tab that was updated. + * @param changeInfo - The change information. + * @param tab - The updated tab. + */ + private handleTabOnUpdated = async ( + tabId: number, + changeInfo: chrome.tabs.TabChangeInfo, + tab: chrome.tabs.Tab + ) => { + const removePageDetailsStatus = new Set(["loading", "unloaded"]); + if (removePageDetailsStatus.has(changeInfo.status)) { + this.overlayBackground.removePageDetails(tabId); + } + + if (this.focusedWindowId > 0 && tab.windowId !== this.focusedWindowId) { + return; + } + + if (!tab.active) { + return; + } + + await this.overlayBackground.updateOverlayCiphers(); + + if (this.main.onUpdatedRan) { + return; + } + this.main.onUpdatedRan = true; + + await this.notificationBackground.checkNotificationQueue(tab); + await this.main.refreshBadge(); + await this.main.refreshMenu(); + this.main.messagingService.send("tabChanged"); + }; + + /** + * Handles the tab onRemoved event. + * + * @param tabId - The ID of the tab that was removed. + */ + private handleTabOnRemoved = async (tabId: number) => { + this.overlayBackground.removePageDetails(tabId); + }; + + /** + * Updates the current tab data, refreshing the badge and context menu + * for the current tab. Also updates the overlay ciphers. + */ + private updateCurrentTabData = async () => { + await this.main.refreshBadge(); + await this.main.refreshMenu(); + await this.overlayBackground.updateOverlayCiphers(); + }; } diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts index 5c6b5e07da3..f1b2fe3339e 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts @@ -15,7 +15,7 @@ import { AUTOFILL_ID, COPY_PASSWORD_ID, COPY_USERNAME_ID, - COPY_VERIFICATIONCODE_ID, + COPY_VERIFICATION_CODE_ID, GENERATE_PASSWORD_ID, NOOP_COMMAND_SUFFIX, } from "../constants"; @@ -165,7 +165,7 @@ describe("ContextMenuClickedHandler", () => { return Promise.resolve("654321"); }); - await sut.run(createData(`${COPY_VERIFICATIONCODE_ID}_1`, COPY_VERIFICATIONCODE_ID), { + await sut.run(createData(`${COPY_VERIFICATION_CODE_ID}_1`, COPY_VERIFICATION_CODE_ID), { url: "https://test.com", } as any); diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index e7f57a46869..a2e139fbee8 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -44,7 +44,7 @@ import { COPY_IDENTIFIER_ID, COPY_PASSWORD_ID, COPY_USERNAME_ID, - COPY_VERIFICATIONCODE_ID, + COPY_VERIFICATION_CODE_ID, CREATE_CARD_ID, CREATE_IDENTITY_ID, CREATE_LOGIN_ID, @@ -281,7 +281,7 @@ export class ContextMenuClickedHandler { } break; - case COPY_VERIFICATIONCODE_ID: + case COPY_VERIFICATION_CODE_ID: if (menuItemId === CREATE_LOGIN_ID) { await openAddEditVaultItemPopout(tab, { cipherType: CipherType.Login }); break; @@ -290,7 +290,7 @@ export class ContextMenuClickedHandler { if (await this.isPasswordRepromptRequired(cipher)) { await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, - action: COPY_VERIFICATIONCODE_ID, + action: COPY_VERIFICATION_CODE_ID, }); } else { this.copyToClipboard({ diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index cc5cc9a5166..20e176cf5c4 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -28,7 +28,7 @@ import { COPY_IDENTIFIER_ID, COPY_PASSWORD_ID, COPY_USERNAME_ID, - COPY_VERIFICATIONCODE_ID, + COPY_VERIFICATION_CODE_ID, CREATE_CARD_ID, CREATE_IDENTITY_ID, CREATE_LOGIN_ID, @@ -139,7 +139,7 @@ export class MainContextMenuHandler { if (await this.stateService.getCanAccessPremium()) { await create({ - id: COPY_VERIFICATIONCODE_ID, + id: COPY_VERIFICATION_CODE_ID, parentId: ROOT_ID, title: this.i18nService.t("copyVerificationCode"), }); @@ -250,7 +250,7 @@ export class MainContextMenuHandler { const canAccessPremium = await this.stateService.getCanAccessPremium(); if (canAccessPremium && (!cipher || !Utils.isNullOrEmpty(cipher.login?.totp))) { - await createChildItem(COPY_VERIFICATIONCODE_ID); + await createChildItem(COPY_VERIFICATION_CODE_ID); } if ((!cipher || cipher.type === CipherType.Card) && optionId !== CREATE_LOGIN_ID) { diff --git a/apps/browser/src/autofill/constants.ts b/apps/browser/src/autofill/constants.ts index c07d829f723..2c361723ad2 100644 --- a/apps/browser/src/autofill/constants.ts +++ b/apps/browser/src/autofill/constants.ts @@ -10,16 +10,27 @@ export const EVENTS = { KEYDOWN: "keydown", KEYPRESS: "keypress", KEYUP: "keyup", + BLUR: "blur", + CLICK: "click", + FOCUS: "focus", + SCROLL: "scroll", + RESIZE: "resize", + DOMCONTENTLOADED: "DOMContentLoaded", + LOAD: "load", + MESSAGE: "message", + VISIBILITYCHANGE: "visibilitychange", + FOCUSOUT: "focusout", } as const; /* Context Menu item Ids */ export const AUTOFILL_CARD_ID = "autofill-card"; export const AUTOFILL_ID = "autofill"; +export const SHOW_AUTOFILL_BUTTON = "show-autofill-button"; export const AUTOFILL_IDENTITY_ID = "autofill-identity"; export const COPY_IDENTIFIER_ID = "copy-identifier"; export const COPY_PASSWORD_ID = "copy-password"; export const COPY_USERNAME_ID = "copy-username"; -export const COPY_VERIFICATIONCODE_ID = "copy-totp"; +export const COPY_VERIFICATION_CODE_ID = "copy-totp"; export const CREATE_CARD_ID = "create-card"; export const CREATE_IDENTITY_ID = "create-identity"; export const CREATE_LOGIN_ID = "create-login"; diff --git a/apps/browser/src/autofill/content/abstractions/autofill-init.ts b/apps/browser/src/autofill/content/abstractions/autofill-init.ts index 24a04d16707..e15cac15331 100644 --- a/apps/browser/src/autofill/content/abstractions/autofill-init.ts +++ b/apps/browser/src/autofill/content/abstractions/autofill-init.ts @@ -1,3 +1,5 @@ +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; + import AutofillScript from "../../models/autofill-script"; type AutofillExtensionMessage = { @@ -7,13 +9,30 @@ type AutofillExtensionMessage = { fillScript?: AutofillScript; url?: string; pageDetailsUrl?: string; + ciphers?: any; + data?: { + authStatus?: AuthenticationStatus; + isFocusingFieldElement?: boolean; + isOverlayCiphersPopulated?: boolean; + direction?: "previous" | "next"; + isOpeningFullOverlay?: boolean; + }; }; +type AutofillExtensionMessageParam = { message: AutofillExtensionMessage }; + type AutofillExtensionMessageHandlers = { [key: string]: CallableFunction; - collectPageDetails: (message: { message: AutofillExtensionMessage }) => void; - collectPageDetailsImmediately: (message: { message: AutofillExtensionMessage }) => void; - fillForm: (message: { message: AutofillExtensionMessage }) => void; + collectPageDetails: ({ message }: AutofillExtensionMessageParam) => void; + collectPageDetailsImmediately: ({ message }: AutofillExtensionMessageParam) => void; + fillForm: ({ message }: AutofillExtensionMessageParam) => void; + openAutofillOverlay: ({ message }: AutofillExtensionMessageParam) => void; + closeAutofillOverlay: () => void; + addNewVaultItemFromOverlay: () => void; + redirectOverlayFocusOut: ({ message }: AutofillExtensionMessageParam) => void; + updateIsOverlayCiphersPopulated: ({ message }: AutofillExtensionMessageParam) => void; + bgUnlockPopoutOpened: () => void; + bgVaultItemRepromptPopoutOpened: () => void; }; interface AutofillInit { diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index d808984be68..93614970ec5 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -1,16 +1,22 @@ import { mock } from "jest-mock-extended"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; + +import { flushPromises, sendExtensionRuntimeMessage } from "../jest/testing-utils"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; +import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; +import { RedirectFocusDirection } from "../utils/autofill-overlay.enum"; import { AutofillExtensionMessage } from "./abstractions/autofill-init"; +import AutofillInit from "./autofill-init"; describe("AutofillInit", () => { - let bitwardenAutofillInit: any; + let autofillInit: AutofillInit; + const autofillOverlayContentService = mock(); beforeEach(() => { - require("../content/autofill-init"); - bitwardenAutofillInit = window.bitwardenAutofillInit; + autofillInit = new AutofillInit(autofillOverlayContentService); }); afterEach(() => { @@ -20,93 +26,11 @@ describe("AutofillInit", () => { describe("init", () => { it("sets up the extension message listeners", () => { - jest.spyOn(bitwardenAutofillInit, "setupExtensionMessageListeners"); + jest.spyOn(autofillInit as any, "setupExtensionMessageListeners"); - bitwardenAutofillInit.init(); + autofillInit.init(); - expect(bitwardenAutofillInit.setupExtensionMessageListeners).toHaveBeenCalled(); - }); - }); - - describe("collectPageDetails", () => { - let extensionMessage: AutofillExtensionMessage; - let pageDetails: AutofillPageDetails; - - beforeEach(() => { - extensionMessage = { - command: "collectPageDetails", - tab: mock(), - sender: "sender", - }; - pageDetails = { - title: "title", - url: "http://example.com", - documentUrl: "documentUrl", - forms: {}, - fields: [], - collectedTimestamp: 0, - }; - jest - .spyOn(bitwardenAutofillInit.collectAutofillContentService, "getPageDetails") - .mockReturnValue(pageDetails); - }); - - it("returns collected page details for autofill if set to send the details in the response", async () => { - const response = await bitwardenAutofillInit["collectPageDetails"](extensionMessage, true); - - expect(bitwardenAutofillInit.collectAutofillContentService.getPageDetails).toHaveBeenCalled(); - expect(response).toEqual(pageDetails); - }); - - it("sends the collected page details for autofill using a background script message", async () => { - jest.spyOn(chrome.runtime, "sendMessage"); - - await bitwardenAutofillInit["collectPageDetails"](extensionMessage); - - expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ - command: "collectPageDetailsResponse", - tab: extensionMessage.tab, - details: pageDetails, - sender: extensionMessage.sender, - }); - }); - }); - - describe("fillForm", () => { - beforeEach(() => { - jest - .spyOn(bitwardenAutofillInit.insertAutofillContentService, "fillForm") - .mockImplementation(); - }); - - it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", () => { - const fillScript = mock(); - const message = { - command: "fillForm", - fillScript, - pageDetailsUrl: "https://a-different-url.com", - }; - - bitwardenAutofillInit.fillForm(message); - - expect(bitwardenAutofillInit.insertAutofillContentService.fillForm).not.toHaveBeenCalledWith( - fillScript - ); - }); - - it("will call the InsertAutofillContentService to fill the form", () => { - const fillScript = mock(); - const message = { - command: "fillForm", - fillScript, - pageDetailsUrl: window.location.href, - }; - - bitwardenAutofillInit.fillForm(message); - - expect(bitwardenAutofillInit.insertAutofillContentService.fillForm).toHaveBeenCalledWith( - fillScript - ); + expect(autofillInit["setupExtensionMessageListeners"]).toHaveBeenCalled(); }); }); @@ -114,10 +38,10 @@ describe("AutofillInit", () => { it("sets up a chrome runtime on message listener", () => { jest.spyOn(chrome.runtime.onMessage, "addListener"); - bitwardenAutofillInit["setupExtensionMessageListeners"](); + autofillInit["setupExtensionMessageListeners"](); expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledWith( - bitwardenAutofillInit["handleExtensionMessage"] + autofillInit["handleExtensionMessage"] ); }); }); @@ -136,38 +60,27 @@ describe("AutofillInit", () => { sender = mock(); }); - it("returns a false value if a extension message handler is not found with the given message command", () => { + it("returns a undefined value if a extension message handler is not found with the given message command", () => { message.command = "unknownCommand"; - const response = bitwardenAutofillInit["handleExtensionMessage"]( - message, - sender, - sendResponse - ); + const response = autofillInit["handleExtensionMessage"](message, sender, sendResponse); - expect(response).toBe(false); + expect(response).toBe(undefined); }); - it("returns a false value if the message handler does not return a response", async () => { - const response1 = await bitwardenAutofillInit["handleExtensionMessage"]( - message, - sender, - sendResponse - ); - await Promise.resolve(response1); + it("returns a undefined value if the message handler does not return a response", async () => { + const response1 = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); + await flushPromises(); expect(response1).not.toBe(false); - message.command = "fillForm"; + message.command = "removeAutofillOverlay"; message.fillScript = mock(); - const response2 = await bitwardenAutofillInit["handleExtensionMessage"]( - message, - sender, - sendResponse - ); + const response2 = autofillInit["handleExtensionMessage"](message, sender, sendResponse); + await flushPromises(); - expect(response2).toBe(false); + expect(response2).toBe(undefined); }); it("returns a true value and calls sendResponse if the message handler returns a response", async () => { @@ -181,18 +94,365 @@ describe("AutofillInit", () => { collectedTimestamp: 0, }; jest - .spyOn(bitwardenAutofillInit.collectAutofillContentService, "getPageDetails") - .mockReturnValue(pageDetails); + .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") + .mockResolvedValue(pageDetails); - const response = await bitwardenAutofillInit["handleExtensionMessage"]( - message, - sender, - sendResponse - ); + const response = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); await Promise.resolve(response); expect(response).toBe(true); expect(sendResponse).toHaveBeenCalledWith(pageDetails); }); + + describe("extension message handlers", () => { + beforeEach(() => { + autofillInit.init(); + }); + + describe("collectPageDetails", () => { + it("sends the collected page details for autofill using a background script message", async () => { + const pageDetails: AutofillPageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + const message = { + command: "collectPageDetails", + sender: "sender", + tab: mock(), + }; + jest + .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") + .mockResolvedValue(pageDetails); + + sendExtensionRuntimeMessage(message, sender, sendResponse); + await flushPromises(); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + command: "collectPageDetailsResponse", + tab: message.tab, + details: pageDetails, + sender: message.sender, + }); + }); + }); + + describe("collectPageDetailsImmediately", () => { + it("returns collected page details for autofill if set to send the details in the response", async () => { + const pageDetails: AutofillPageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + jest + .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") + .mockResolvedValue(pageDetails); + + sendExtensionRuntimeMessage( + { command: "collectPageDetailsImmediately" }, + sender, + sendResponse + ); + await flushPromises(); + + expect(autofillInit["collectAutofillContentService"].getPageDetails).toHaveBeenCalled(); + expect(sendResponse).toBeCalledWith(pageDetails); + expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); + }); + }); + + describe("fillForm", () => { + let fillScript: AutofillScript; + beforeEach(() => { + fillScript = mock(); + jest.spyOn(autofillInit["insertAutofillContentService"], "fillForm").mockImplementation(); + }); + + it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", async () => { + const fillScript = mock(); + const message = { + command: "fillForm", + fillScript, + pageDetailsUrl: "https://a-different-url.com", + }; + + sendExtensionRuntimeMessage(message); + await flushPromises(); + + expect(autofillInit["insertAutofillContentService"].fillForm).not.toHaveBeenCalledWith( + fillScript + ); + }); + + it("calls the InsertAutofillContentService to fill the form", async () => { + sendExtensionRuntimeMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + + expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( + fillScript + ); + }); + + it("updates the isCurrentlyFilling properties of the overlay and focus the recent field after filling", async () => { + jest.useFakeTimers(); + jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling"); + jest + .spyOn(autofillInit["autofillOverlayContentService"], "focusMostRecentOverlayField") + .mockImplementation(); + + sendExtensionRuntimeMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + jest.advanceTimersByTime(300); + + expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(1, true); + expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( + fillScript + ); + expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false); + expect( + autofillInit["autofillOverlayContentService"].focusMostRecentOverlayField + ).toHaveBeenCalled(); + }); + + it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => { + jest.useFakeTimers(); + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "updateOverlayIsCurrentlyFilling"); + jest + .spyOn(newAutofillInit["insertAutofillContentService"], "fillForm") + .mockImplementation(); + + sendExtensionRuntimeMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + jest.advanceTimersByTime(300); + + expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith( + 1, + true + ); + expect(newAutofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( + fillScript + ); + expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).not.toHaveBeenNthCalledWith( + 2, + false + ); + }); + }); + + describe("openAutofillOverlay", () => { + const message = { + command: "openAutofillOverlay", + data: { + isFocusingFieldElement: true, + isOpeningFullOverlay: true, + authStatus: AuthenticationStatus.Unlocked, + }, + }; + + it("skips attempting to open the autofill overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + + sendExtensionRuntimeMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("opens the autofill overlay", () => { + sendExtensionRuntimeMessage(message); + + expect( + autofillInit["autofillOverlayContentService"].openAutofillOverlay + ).toHaveBeenCalledWith({ + isFocusingFieldElement: message.data.isFocusingFieldElement, + isOpeningFullOverlay: message.data.isOpeningFullOverlay, + authStatus: message.data.authStatus, + }); + }); + }); + + describe("closeAutofillOverlay", () => { + beforeEach(() => { + autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = false; + autofillInit["autofillOverlayContentService"].isCurrentlyFilling = false; + }); + + it("ignores the message if a field is currently focused", () => { + autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = true; + + sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlayList + ).not.toHaveBeenCalled(); + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay + ).not.toHaveBeenCalled(); + }); + + it("removes the autofill overlay list if the overlay is currently filling", () => { + autofillInit["autofillOverlayContentService"].isCurrentlyFilling = true; + + sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlayList + ).toHaveBeenCalled(); + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay + ).not.toHaveBeenCalled(); + }); + + it("removes the entire overlay if the overlay is not currently filling", () => { + sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlayList + ).not.toHaveBeenCalled(); + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay + ).toHaveBeenCalled(); + }); + }); + + describe("addNewVaultItemFromOverlay", () => { + it("will not add a new vault item if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + + sendExtensionRuntimeMessage({ command: "addNewVaultItemFromOverlay" }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("will add a new vault item", () => { + sendExtensionRuntimeMessage({ command: "addNewVaultItemFromOverlay" }); + + expect(autofillInit["autofillOverlayContentService"].addNewVaultItem).toHaveBeenCalled(); + }); + }); + + describe("redirectOverlayFocusOut", () => { + const message = { + command: "redirectOverlayFocusOut", + data: { + direction: RedirectFocusDirection.Next, + }, + }; + + it("ignores the message to redirect focus if the autofillOverlayContentService does not exist", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + + sendExtensionRuntimeMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("redirects the overlay focus", () => { + sendExtensionRuntimeMessage(message); + + expect( + autofillInit["autofillOverlayContentService"].redirectOverlayFocusOut + ).toHaveBeenCalledWith(message.data.direction); + }); + }); + + describe("updateIsOverlayCiphersPopulated", () => { + const message = { + command: "updateIsOverlayCiphersPopulated", + data: { + isOverlayCiphersPopulated: true, + }, + }; + + it("skips updating whether the ciphers are populated if the autofillOverlayContentService does note exist", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + + sendExtensionRuntimeMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("updates whether the overlay ciphers are populated", () => { + sendExtensionRuntimeMessage(message); + + expect(autofillInit["autofillOverlayContentService"].isOverlayCiphersPopulated).toEqual( + message.data.isOverlayCiphersPopulated + ); + }); + }); + + describe("bgUnlockPopoutOpened", () => { + it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); + + sendExtensionRuntimeMessage({ command: "bgUnlockPopoutOpened" }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); + }); + + it("blurs the most recently focused feel and remove the autofill overlay", () => { + jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); + jest.spyOn(autofillInit as any, "removeAutofillOverlay"); + + sendExtensionRuntimeMessage({ command: "bgUnlockPopoutOpened" }); + + expect( + autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField + ).toHaveBeenCalled(); + expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); + }); + }); + + describe("bgVaultItemRepromptPopoutOpened", () => { + it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); + + sendExtensionRuntimeMessage({ command: "bgVaultItemRepromptPopoutOpened" }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); + }); + + it("blurs the most recently focused feel and remove the autofill overlay", () => { + jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); + jest.spyOn(autofillInit as any, "removeAutofillOverlay"); + + sendExtensionRuntimeMessage({ command: "bgVaultItemRepromptPopoutOpened" }); + + expect( + autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField + ).toHaveBeenCalled(); + expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index 99c64b3779b..0bd04edee4b 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -1,4 +1,5 @@ import AutofillPageDetails from "../models/autofill-page-details"; +import { AutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service"; import CollectAutofillContentService from "../services/collect-autofill-content.service"; import DomElementVisibilityService from "../services/dom-element-visibility.service"; import InsertAutofillContentService from "../services/insert-autofill-content.service"; @@ -10,6 +11,7 @@ import { } from "./abstractions/autofill-init"; class AutofillInit implements AutofillInitInterface { + private readonly autofillOverlayContentService: AutofillOverlayContentService | undefined; private readonly domElementVisibilityService: DomElementVisibilityService; private readonly collectAutofillContentService: CollectAutofillContentService; private readonly insertAutofillContentService: InsertAutofillContentService; @@ -17,16 +19,27 @@ class AutofillInit implements AutofillInitInterface { collectPageDetails: ({ message }) => this.collectPageDetails(message), collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true), fillForm: ({ message }) => this.fillForm(message), + openAutofillOverlay: ({ message }) => this.openAutofillOverlay(message), + closeAutofillOverlay: () => this.removeAutofillOverlay(), + addNewVaultItemFromOverlay: () => this.addNewVaultItemFromOverlay(), + redirectOverlayFocusOut: ({ message }) => this.redirectOverlayFocusOut(message), + updateIsOverlayCiphersPopulated: ({ message }) => this.updateIsOverlayCiphersPopulated(message), + bgUnlockPopoutOpened: () => this.blurAndRemoveOverlay(), + bgVaultItemRepromptPopoutOpened: () => this.blurAndRemoveOverlay(), }; /** * AutofillInit constructor. Initializes the DomElementVisibilityService, * CollectAutofillContentService and InsertAutofillContentService classes. + * + * @param autofillOverlayContentService - The autofill overlay content service, potentially undefined. */ - constructor() { + constructor(autofillOverlayContentService?: AutofillOverlayContentService) { + this.autofillOverlayContentService = autofillOverlayContentService; this.domElementVisibilityService = new DomElementVisibilityService(); this.collectAutofillContentService = new CollectAutofillContentService( - this.domElementVisibilityService + this.domElementVisibilityService, + this.autofillOverlayContentService ); this.insertAutofillContentService = new InsertAutofillContentService( this.domElementVisibilityService, @@ -38,10 +51,10 @@ class AutofillInit implements AutofillInitInterface { * Initializes the autofill content script, setting up * the extension message listeners. This method should * be called once when the content script is loaded. - * @public */ init() { this.setupExtensionMessageListeners(); + this.autofillOverlayContentService?.init(); } /** @@ -50,10 +63,9 @@ class AutofillInit implements AutofillInitInterface { * parameter is set to true, the page details will be * returned to facilitate sending the details in the * response to the extension message. - * @param {AutofillExtensionMessage} message - * @param {boolean} sendDetailsInResponse - * @returns {AutofillPageDetails | void} - * @private + * + * @param message - The extension message. + * @param sendDetailsInResponse - Determines whether to send the details in the response. */ private async collectPageDetails( message: AutofillExtensionMessage, @@ -78,31 +90,138 @@ class AutofillInit implements AutofillInitInterface { * * @param {AutofillExtensionMessage} message */ - private fillForm({ fillScript, pageDetailsUrl }: AutofillExtensionMessage) { + private async fillForm({ fillScript, pageDetailsUrl }: AutofillExtensionMessage) { if ((document.defaultView || window).location.href !== pageDetailsUrl) { return; } - this.insertAutofillContentService.fillForm(fillScript); + this.updateOverlayIsCurrentlyFilling(true); + await this.insertAutofillContentService.fillForm(fillScript); + + if (!this.autofillOverlayContentService) { + return; + } + + setTimeout(() => { + this.updateOverlayIsCurrentlyFilling(false); + this.autofillOverlayContentService.focusMostRecentOverlayField(); + }, 250); } /** - * Sets up the extension message listeners - * for the content script. - * @private + * Handles updating the overlay is currently filling value. + * + * @param isCurrentlyFilling - Indicates if the overlay is currently filling + */ + private updateOverlayIsCurrentlyFilling(isCurrentlyFilling: boolean) { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.isCurrentlyFilling = isCurrentlyFilling; + } + + /** + * Opens the autofill overlay. + * + * @param data - The extension message data. + */ + private openAutofillOverlay({ data }: AutofillExtensionMessage) { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.openAutofillOverlay(data); + } + + /** + * Blurs the most recent overlay field and removes the overlay. Used + * in cases where the background unlock or vault item reprompt popout + * is opened. + */ + private blurAndRemoveOverlay() { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.blurMostRecentOverlayField(); + this.removeAutofillOverlay(); + } + + /** + * Removes the autofill overlay if the field is not currently focused. + * If the autofill is currently filling, only the overlay list will be + * removed. + */ + private removeAutofillOverlay() { + if ( + !this.autofillOverlayContentService || + this.autofillOverlayContentService.isFieldCurrentlyFocused + ) { + return; + } + + if (this.autofillOverlayContentService.isCurrentlyFilling) { + this.autofillOverlayContentService.removeAutofillOverlayList(); + return; + } + + this.autofillOverlayContentService.removeAutofillOverlay(); + } + + /** + * Adds a new vault item from the overlay. + */ + private addNewVaultItemFromOverlay() { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.addNewVaultItem(); + } + + /** + * Redirects the overlay focus out of an overlay iframe. + * + * @param data - Contains the direction to redirect the focus. + */ + private redirectOverlayFocusOut({ data }: AutofillExtensionMessage) { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.redirectOverlayFocusOut(data?.direction); + } + + /** + * Updates whether the current tab has ciphers that can populate the overlay list + * + * @param data - Contains the isOverlayCiphersPopulated value + * + */ + private updateIsOverlayCiphersPopulated({ data }: AutofillExtensionMessage) { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillOverlayContentService.isOverlayCiphersPopulated = Boolean( + data?.isOverlayCiphersPopulated + ); + } + + /** + * Sets up the extension message listeners for the content script. */ private setupExtensionMessageListeners() { chrome.runtime.onMessage.addListener(this.handleExtensionMessage); } /** - * Handles the extension messages - * sent to the content script. - * @param {AutofillExtensionMessage} message - * @param {chrome.runtime.MessageSender} sender - * @param {(response?: any) => void} sendResponse - * @returns {boolean} - * @private + * Handles the extension messages sent to the content script. + * + * @param message - The extension message. + * @param sender - The message sender. + * @param sendResponse - The send response callback. */ private handleExtensionMessage = ( message: AutofillExtensionMessage, @@ -112,12 +231,12 @@ class AutofillInit implements AutofillInitInterface { const command: string = message.command; const handler: CallableFunction | undefined = this.extensionMessageHandlers[command]; if (!handler) { - return false; + return; } const messageResponse = handler({ message, sender }); if (!messageResponse) { - return false; + return; } Promise.resolve(messageResponse).then((response) => sendResponse(response)); @@ -125,9 +244,4 @@ class AutofillInit implements AutofillInitInterface { }; } -(function () { - if (!window.bitwardenAutofillInit) { - window.bitwardenAutofillInit = new AutofillInit(); - window.bitwardenAutofillInit.init(); - } -})(); +export default AutofillInit; diff --git a/apps/browser/src/autofill/content/autofill.css b/apps/browser/src/autofill/content/autofill.css index e495cbf5c45..cbdb776fafa 100644 --- a/apps/browser/src/autofill/content/autofill.css +++ b/apps/browser/src/autofill/content/autofill.css @@ -34,3 +34,10 @@ span[data-bwautofill].com-bitwarden-browser-animated-fill { animation: bitwardenfill 200ms ease-in-out 0ms 1; -webkit-animation: bitwardenfill 200ms ease-in-out 0ms 1; } + +@media (prefers-reduced-motion) { + .com-bitwarden-browser-animated-fill { + animation: none; + -webkit-animation: none; + } +} diff --git a/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts new file mode 100644 index 00000000000..5bc9fb1718f --- /dev/null +++ b/apps/browser/src/autofill/content/bootstrap-autofill-overlay.ts @@ -0,0 +1,11 @@ +import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; + +import AutofillInit from "./autofill-init"; + +(function (windowContext) { + if (!windowContext.bitwardenAutofillInit) { + const autofillOverlayContentService = new AutofillOverlayContentService(); + windowContext.bitwardenAutofillInit = new AutofillInit(autofillOverlayContentService); + windowContext.bitwardenAutofillInit.init(); + } +})(window); diff --git a/apps/browser/src/autofill/content/bootstrap-autofill.ts b/apps/browser/src/autofill/content/bootstrap-autofill.ts new file mode 100644 index 00000000000..3264c77ea0e --- /dev/null +++ b/apps/browser/src/autofill/content/bootstrap-autofill.ts @@ -0,0 +1,8 @@ +import AutofillInit from "./autofill-init"; + +(function (windowContext) { + if (!windowContext.bitwardenAutofillInit) { + windowContext.bitwardenAutofillInit = new AutofillInit(); + windowContext.bitwardenAutofillInit.init(); + } +})(window); diff --git a/apps/browser/src/autofill/content/notification-bar.ts b/apps/browser/src/autofill/content/notification-bar.ts index 29ba7ca9fff..223d6ab1ddf 100644 --- a/apps/browser/src/autofill/content/notification-bar.ts +++ b/apps/browser/src/autofill/content/notification-bar.ts @@ -3,7 +3,7 @@ import ChangePasswordRuntimeMessage from "../../background/models/changePassword import AutofillField from "../models/autofill-field"; import { WatchedForm } from "../models/watched-form"; import { FormData } from "../services/abstractions/autofill.service"; -import { UserSettings } from "../types"; +import { GlobalSettings, UserSettings } from "../types"; interface HTMLElementWithFormOpId extends HTMLElement { formOpId: string; @@ -97,6 +97,7 @@ async function loadNotificationBar() { const userSettingsStorageValue = await getFromLocalStorage(activeUserId); if (userSettingsStorageValue[activeUserId]) { const userSettings: UserSettings = userSettingsStorageValue[activeUserId].settings; + const globalSettings: GlobalSettings = await getFromLocalStorage("global"); // Do not show the notification bar on the Bitwarden vault // because they can add logins and change passwords there @@ -107,11 +108,11 @@ async function loadNotificationBar() { // show the notification bar on (for login detail collection or password change). // It is managed in the Settings > Excluded Domains page in the browser extension. // Example: '{"bitwarden.com":null}' - const excludedDomainsDict = userSettings.neverDomains; + const excludedDomainsDict = globalSettings.neverDomains; if (!excludedDomainsDict || !(window.location.hostname in excludedDomainsDict)) { // Set local disabled preferences - disabledAddLoginNotification = userSettings.disableAddLoginNotification; - disabledChangedPasswordNotification = userSettings.disableChangedPasswordNotification; + disabledAddLoginNotification = globalSettings.disableAddLoginNotification; + disabledChangedPasswordNotification = globalSettings.disableChangedPasswordNotification; if (!disabledAddLoginNotification || !disabledChangedPasswordNotification) { // If the user has not disabled both notifications, then handle the initial page change (null -> actual page) diff --git a/apps/browser/src/autofill/jest/autofill-mocks.ts b/apps/browser/src/autofill/jest/autofill-mocks.ts index 69c82934fa4..aec61f9df56 100644 --- a/apps/browser/src/autofill/jest/autofill-mocks.ts +++ b/apps/browser/src/autofill/jest/autofill-mocks.ts @@ -1,12 +1,18 @@ import { mock } from "jest-mock-extended"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { UriMatchType } from "@bitwarden/common/enums"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { OverlayCipherData } from "../background/abstractions/overlay.background"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript, { FillScript } from "../models/autofill-script"; -import { GenerateFillScriptOptions } from "../services/abstractions/autofill.service"; +import { InitAutofillOverlayButtonMessage } from "../overlay/abstractions/autofill-overlay-button"; +import { InitAutofillOverlayListMessage } from "../overlay/abstractions/autofill-overlay-list"; +import { GenerateFillScriptOptions, PageDetail } from "../services/abstractions/autofill.service"; function createAutofillFieldMock(customFields = {}): AutofillField { return { @@ -38,6 +44,15 @@ function createAutofillFieldMock(customFields = {}): AutofillField { }; } +function createPageDetailMock(customFields = {}): PageDetail { + return { + frameId: 0, + tab: createChromeTabMock(), + details: createAutofillPageDetailsMock(), + ...customFields, + }; +} + function createAutofillPageDetailsMock(customFields = {}): AutofillPageDetails { return { title: "title", @@ -71,7 +86,7 @@ function createChromeTabMock(customFields = {}): chrome.tabs.Tab { discarded: false, autoDiscardable: false, groupId: 2, - url: "https://tacos.com", + url: "https://jest-testing-website.com", ...customFields, }; } @@ -84,7 +99,7 @@ function createGenerateFillScriptOptionsMock(customFields = {}): GenerateFillScr fillNewPassword: false, allowTotpAutofill: false, cipher: mock(), - tabUrl: "https://tacos.com", + tabUrl: "https://jest-testing-website.com", defaultUriMatch: UriMatchType.Domain, ...customFields, }; @@ -122,10 +137,135 @@ function createAutofillScriptMock( }; } +const overlayPagesTranslations = { + locale: "en", + buttonPageTitle: "buttonPageTitle", + listPageTitle: "listPageTitle", + opensInANewWindow: "opensInANewWindow", + toggleBitwardenVaultOverlay: "toggleBitwardenVaultOverlay", + unlockYourAccount: "unlockYourAccount", + unlockAccount: "unlockAccount", + fillCredentialsFor: "fillCredentialsFor", + partialUsername: "partialUsername", + view: "view", + noItemsToShow: "noItemsToShow", + newItem: "newItem", + addNewVaultItem: "addNewVaultItem", +}; +function createInitAutofillOverlayButtonMessageMock( + customFields = {} +): InitAutofillOverlayButtonMessage { + return { + command: "initAutofillOverlayButton", + translations: overlayPagesTranslations, + styleSheetUrl: "https://jest-testing-website.com", + authStatus: AuthenticationStatus.Unlocked, + ...customFields, + }; +} +function createAutofillOverlayCipherDataMock(index: number, customFields = {}): OverlayCipherData { + return { + id: String(index), + name: `website login ${index}`, + login: { username: `username${index}` }, + type: CipherType.Login, + reprompt: CipherRepromptType.None, + favorite: false, + icon: { + imageEnabled: true, + image: "https://jest-testing-website.com/image.png", + fallbackImage: "https://jest-testing-website.com/fallback.png", + icon: "bw-icon", + }, + ...customFields, + }; +} + +function createInitAutofillOverlayListMessageMock( + customFields = {} +): InitAutofillOverlayListMessage { + return { + command: "initAutofillOverlayList", + translations: overlayPagesTranslations, + styleSheetUrl: "https://jest-testing-website.com", + theme: "light", + authStatus: AuthenticationStatus.Unlocked, + ciphers: [ + createAutofillOverlayCipherDataMock(1, { + icon: { + imageEnabled: true, + image: "https://jest-testing-website.com/image.png", + fallbackImage: "", + icon: "bw-icon", + }, + }), + createAutofillOverlayCipherDataMock(2, { + icon: { + imageEnabled: true, + image: "", + fallbackImage: "https://jest-testing-website.com/fallback.png", + icon: "bw-icon", + }, + }), + createAutofillOverlayCipherDataMock(3, { + name: "", + login: { username: "" }, + icon: { imageEnabled: true, image: "", fallbackImage: "", icon: "bw-icon" }, + }), + createAutofillOverlayCipherDataMock(4, { + icon: { imageEnabled: false, image: "", fallbackImage: "", icon: "" }, + }), + createAutofillOverlayCipherDataMock(5), + createAutofillOverlayCipherDataMock(6), + createAutofillOverlayCipherDataMock(7), + createAutofillOverlayCipherDataMock(8), + ], + ...customFields, + }; +} + +function createFocusedFieldDataMock(customFields = {}) { + return { + focusedFieldRects: { + top: 1, + left: 2, + height: 3, + width: 4, + }, + focusedFieldStyles: { + paddingRight: "6px", + paddingLeft: "6px", + }, + ...customFields, + }; +} + +function createPortSpyMock(name: string) { + return mock({ + name, + onMessage: { + addListener: jest.fn(), + removeListener: jest.fn(), + }, + onDisconnect: { + addListener: jest.fn(), + }, + postMessage: jest.fn(), + sender: { + tab: createChromeTabMock(), + }, + }); +} + export { createAutofillFieldMock, + createPageDetailMock, createAutofillPageDetailsMock, createChromeTabMock, createGenerateFillScriptOptionsMock, createAutofillScriptMock, + createInitAutofillOverlayButtonMessageMock, + createInitAutofillOverlayListMessageMock, + createFocusedFieldDataMock, + createPortSpyMock, }; diff --git a/apps/browser/src/autofill/jest/testing-utils.ts b/apps/browser/src/autofill/jest/testing-utils.ts index ac5964b7bc6..c3fe951a8ee 100644 --- a/apps/browser/src/autofill/jest/testing-utils.ts +++ b/apps/browser/src/autofill/jest/testing-utils.ts @@ -1,5 +1,104 @@ +import { mock } from "jest-mock-extended"; + function triggerTestFailure() { expect(true).toBe("Test has failed."); } -export { triggerTestFailure }; +const scheduler = typeof setImmediate === "function" ? setImmediate : setTimeout; +function flushPromises() { + return new Promise(function (resolve) { + scheduler(resolve); + }); +} + +function postWindowMessage(data: any, origin = "https://localhost/") { + globalThis.dispatchEvent(new MessageEvent("message", { data, origin })); +} + +function sendExtensionRuntimeMessage( + message: any, + sender?: chrome.runtime.MessageSender, + sendResponse?: CallableFunction +) { + (chrome.runtime.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach( + (call) => { + const callback = call[0]; + callback( + message || {}, + sender || mock(), + sendResponse || jest.fn() + ); + } + ); +} + +function sendPortMessage(port: chrome.runtime.Port, message: any) { + (port.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { + const callback = call[0]; + callback(message || {}, port); + }); +} + +function triggerPortOnDisconnectEvent(port: chrome.runtime.Port) { + (port.onDisconnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { + const callback = call[0]; + callback(port); + }); +} + +function triggerWindowOnFocusedChangedEvent(windowId: number) { + (chrome.windows.onFocusChanged.addListener as unknown as jest.SpyInstance).mock.calls.forEach( + (call) => { + const callback = call[0]; + callback(windowId); + } + ); +} + +function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) { + (chrome.tabs.onActivated.addListener as unknown as jest.SpyInstance).mock.calls.forEach( + (call) => { + const callback = call[0]; + callback(activeInfo); + } + ); +} + +function triggerTabOnReplacedEvent(addedTabId: number, removedTabId: number) { + (chrome.tabs.onReplaced.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { + const callback = call[0]; + callback(addedTabId, removedTabId); + }); +} + +function triggerTabOnUpdatedEvent( + tabId: number, + changeInfo: chrome.tabs.TabChangeInfo, + tab: chrome.tabs.Tab +) { + (chrome.tabs.onUpdated.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { + const callback = call[0]; + callback(tabId, changeInfo, tab); + }); +} + +function triggerTabOnRemovedEvent(tabId: number, removeInfo: chrome.tabs.TabRemoveInfo) { + (chrome.tabs.onRemoved.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { + const callback = call[0]; + callback(tabId, removeInfo); + }); +} + +export { + triggerTestFailure, + flushPromises, + postWindowMessage, + sendExtensionRuntimeMessage, + sendPortMessage, + triggerPortOnDisconnectEvent, + triggerWindowOnFocusedChangedEvent, + triggerTabOnActivatedEvent, + triggerTabOnReplacedEvent, + triggerTabOnUpdatedEvent, + triggerTabOnRemovedEvent, +}; diff --git a/apps/browser/src/autofill/notification/bar.scss b/apps/browser/src/autofill/notification/bar.scss index 3fdfca01a43..20615e024a7 100644 --- a/apps/browser/src/autofill/notification/bar.scss +++ b/apps/browser/src/autofill/notification/bar.scss @@ -1,4 +1,4 @@ -@import "variables.scss"; +@import "../shared/styles/variables"; body { padding: 0; @@ -104,7 +104,7 @@ button.secondary:not(.neutral) { &:hover { @include themify($themes) { - background-color: darken(themed("backgroundColor"), 1.5%); + background-color: themed("backgroundOffsetColor"); color: darken(themed("mutedTextColor"), 6%); } } diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-button.ts b/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-button.ts new file mode 100644 index 00000000000..380a1f5de2e --- /dev/null +++ b/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-button.ts @@ -0,0 +1,27 @@ +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; + +type OverlayButtonMessage = { command: string }; + +type UpdateAuthStatusMessage = OverlayButtonMessage & { authStatus: AuthenticationStatus }; + +type InitAutofillOverlayButtonMessage = UpdateAuthStatusMessage & { + styleSheetUrl: string; + translations: Record; +}; + +type OverlayButtonWindowMessageHandlers = { + [key: string]: CallableFunction; + initAutofillOverlayButton: ({ message }: { message: InitAutofillOverlayButtonMessage }) => void; + checkAutofillOverlayButtonFocused: () => void; + updateAutofillOverlayButtonAuthStatus: ({ + message, + }: { + message: UpdateAuthStatusMessage; + }) => void; +}; + +export { + UpdateAuthStatusMessage, + InitAutofillOverlayButtonMessage, + OverlayButtonWindowMessageHandlers, +}; diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-iframe.service.ts b/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-iframe.service.ts new file mode 100644 index 00000000000..1f7009bece3 --- /dev/null +++ b/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-iframe.service.ts @@ -0,0 +1,32 @@ +type AutofillOverlayIframeExtensionMessage = { + command: string; + styles?: Partial; + theme?: string; +}; + +type AutofillOverlayIframeWindowMessageHandlers = { + [key: string]: CallableFunction; + updateAutofillOverlayListHeight: (message: AutofillOverlayIframeExtensionMessage) => void; +}; + +type AutofillOverlayIframeExtensionMessageParam = { + message: AutofillOverlayIframeExtensionMessage; +}; + +type BackgroundPortMessageHandlers = { + [key: string]: CallableFunction; + initAutofillOverlayList: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void; + updateIframePosition: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void; + updateOverlayHidden: ({ message }: AutofillOverlayIframeExtensionMessageParam) => void; +}; + +interface AutofillOverlayIframeService { + initOverlayIframe(initStyles: Partial, ariaAlert?: string): void; +} + +export { + AutofillOverlayIframeExtensionMessage, + AutofillOverlayIframeWindowMessageHandlers, + BackgroundPortMessageHandlers, + AutofillOverlayIframeService, +}; diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-list.ts b/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-list.ts new file mode 100644 index 00000000000..b656f238dce --- /dev/null +++ b/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-list.ts @@ -0,0 +1,31 @@ +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; + +import { OverlayCipherData } from "../../background/abstractions/overlay.background"; + +type OverlayListMessage = { command: string }; + +type UpdateOverlayListCiphersMessage = OverlayListMessage & { + ciphers: OverlayCipherData[]; +}; + +type InitAutofillOverlayListMessage = OverlayListMessage & { + authStatus: AuthenticationStatus; + styleSheetUrl: string; + theme: string; + translations: Record; + ciphers?: OverlayCipherData[]; +}; + +type OverlayListWindowMessageHandlers = { + [key: string]: CallableFunction; + initAutofillOverlayList: ({ message }: { message: InitAutofillOverlayListMessage }) => void; + checkAutofillOverlayListFocused: () => void; + updateOverlayListCiphers: ({ message }: { message: UpdateOverlayListCiphersMessage }) => void; + focusOverlayList: () => void; +}; + +export { + UpdateOverlayListCiphersMessage, + InitAutofillOverlayListMessage, + OverlayListWindowMessageHandlers, +}; diff --git a/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-page-element.ts b/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-page-element.ts new file mode 100644 index 00000000000..eb3c2fa4a71 --- /dev/null +++ b/apps/browser/src/autofill/overlay/abstractions/autofill-overlay-page-element.ts @@ -0,0 +1,13 @@ +import { OverlayButtonWindowMessageHandlers } from "./autofill-overlay-button"; +import { OverlayListWindowMessageHandlers } from "./autofill-overlay-list"; + +type WindowMessageHandlers = OverlayButtonWindowMessageHandlers | OverlayListWindowMessageHandlers; + +type AutofillOverlayPageElementWindowMessage = { + [key: string]: any; + command: string; + overlayCipherId?: string; + height?: number; +}; + +export { WindowMessageHandlers, AutofillOverlayPageElementWindowMessage }; diff --git a/apps/browser/src/autofill/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.spec.ts.snap b/apps/browser/src/autofill/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.spec.ts.snap new file mode 100644 index 00000000000..cb8e4a541bb --- /dev/null +++ b/apps/browser/src/autofill/overlay/iframe-content/__snapshots__/autofill-overlay-iframe.service.spec.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AutofillOverlayIframeService initOverlayIframe creates an aria alert element if the ariaAlert param is passed 1`] = ` +
+ aria alert +
+`; + +exports[`AutofillOverlayIframeService initOverlayIframe sets up the iframe's attributes 1`] = ` +