From 077abe0518419fa5c2e55ca543650214ca5994c0 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Wed, 25 Sep 2024 16:56:50 -0500 Subject: [PATCH] [PM-12316] Implement inline menu passkeys authenticating state (#11113) --- apps/browser/src/_locales/en/messages.json | 3 + .../overlay-notifications.background.ts | 41 +------ .../background/overlay.background.spec.ts | 115 +++++++++++++----- .../autofill/background/overlay.background.ts | 50 ++++++-- .../autofill-inline-menu-list.spec.ts.snap | 38 ++++++ .../list/autofill-inline-menu-list.spec.ts | 61 ++++++++-- .../pages/list/autofill-inline-menu-list.ts | 89 +++++++++++--- .../overlay/inline-menu/pages/list/list.scss | 39 +++++- .../src/autofill/services/autofill.service.ts | 6 +- .../src/autofill/shared/styles/variables.scss | 5 + apps/browser/src/autofill/utils/index.ts | 47 +++++++ apps/browser/src/autofill/utils/svg-icons.ts | 3 + 12 files changed, 391 insertions(+), 106 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 49b5eb82bb..f7db6d78f2 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4496,5 +4496,8 @@ }, "noEditPermissions": { "message": "You don't have permission to edit this item" + }, + "authenticating": { + "message": "Authenticating" } } diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index e252bdcc4a..ca8f05b77d 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -6,6 +6,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { BrowserApi } from "../../platform/browser/browser-api"; +import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils"; import { ActiveFormSubmissionRequests, @@ -109,35 +110,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg */ private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) { return new Set([ - ...this.generateMatchPatterns(sender.url), - ...this.generateMatchPatterns(sender.tab.url), + ...generateDomainMatchPatterns(sender.url), + ...generateDomainMatchPatterns(sender.tab.url), ]); } - /** - * Generates the origin and subdomain match patterns for the URL. - * - * @param url - The URL of the tab - */ - private generateMatchPatterns(url: string): string[] { - try { - if (!url.startsWith("http")) { - url = `https://${url}`; - } - - const originMatchPattern = `${new URL(url).origin}/*`; - - const parsedUrl = new URL(url); - const splitHost = parsedUrl.hostname.split("."); - const domain = splitHost.slice(-2).join("."); - const subDomainMatchPattern = `${parsedUrl.protocol}//*.${domain}/*`; - - return [originMatchPattern, subDomainMatchPattern]; - } catch { - return []; - } - } - /** * Stores the login form data that was modified by the user in the content script. This data is * used to trigger the add login or change password notification when the form is submitted. @@ -329,7 +306,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private handleOnCompletedRequestEvent = async (details: chrome.webRequest.WebResponseDetails) => { if ( this.requestHostIsInvalid(details) || - this.isInvalidStatusCode(details.statusCode) || + isInvalidResponseStatusCode(details.statusCode) || !this.activeFormSubmissionRequests.has(details.requestId) ) { return; @@ -472,16 +449,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg this.setupWebRequestsListeners(); }; - /** - * Determines if the status code of the web response is invalid. An invalid status code is - * any status code that is not in the 200-299 range. - * - * @param statusCode - The status code of the web response - */ - private isInvalidStatusCode = (statusCode: number) => { - return statusCode < 200 || statusCode >= 300; - }; - /** * Determines if the host of the web request is invalid. An invalid host is any host that does not * start with "http" or a tab id that is less than 0. diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 30f19e7260..b6a04f63d5 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -61,6 +61,7 @@ import { triggerPortOnDisconnectEvent, triggerPortOnMessageEvent, triggerWebNavigationOnCommittedEvent, + triggerWebRequestOnCompletedEvent, } from "../spec/testing-utils"; import { @@ -3003,37 +3004,95 @@ describe("OverlayBackground", () => { expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); }); - it("triggers passkey authentication through mediated conditional UI", async () => { - const fido2Credential = mock({ credentialId: "credential-id" }); - const cipher1 = mock({ - id: "inline-menu-cipher-1", - login: { - username: "username1", - password: "password1", - fido2Credentials: [fido2Credential], - }, - }); - overlayBackground["inlineMenuCiphers"] = new Map([["inline-menu-cipher-1", cipher1]]); - const pageDetailsForTab = { - frameId: sender.frameId, - tab: sender.tab, - details: pageDetails, - }; - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, pageDetailsForTab], - ]); - autofillService.isPasswordRepromptRequired.mockResolvedValue(false); - jest.spyOn(fido2ActiveRequestManager, "getActiveRequest"); + describe("triggering passkey authentication", () => { + let cipher1: CipherView; - sendPortMessage(listMessageConnectorSpy, { - command: "fillAutofillInlineMenuCipher", - inlineMenuCipherId: "inline-menu-cipher-1", - usePasskey: true, - portKey, + beforeEach(() => { + const fido2Credential = mock({ credentialId: "credential-id" }); + cipher1 = mock({ + id: "inline-menu-cipher-1", + login: { + username: "username1", + password: "password1", + fido2Credentials: [fido2Credential], + }, + }); + const pageDetailsForTab = { + frameId: sender.frameId, + tab: sender.tab, + details: pageDetails, + }; + overlayBackground["inlineMenuCiphers"] = new Map([["inline-menu-cipher-1", cipher1]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, pageDetailsForTab], + ]); + autofillService.isPasswordRepromptRequired.mockResolvedValue(false); }); - await flushPromises(); - expect(fido2ActiveRequestManager.getActiveRequest).toHaveBeenCalledWith(sender.tab.id); + it("logs an error if the authentication could not complete due to a missing FIDO2 request", async () => { + jest.spyOn(logService, "error"); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + usePasskey: true, + portKey, + }); + await flushPromises(); + + expect(logService.error).toHaveBeenCalled(); + }); + + describe("when the FIDO2 request is present", () => { + beforeEach(async () => { + void fido2ActiveRequestManager.newActiveRequest( + sender.tab.id, + cipher1.login.fido2Credentials, + new AbortController(), + ); + }); + + it("aborts all active FIDO2 requests if the subsequent request after the authentication is invalid", async () => { + jest.spyOn(fido2ActiveRequestManager, "removeActiveRequest"); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + usePasskey: true, + portKey, + }); + await flushPromises(); + triggerWebRequestOnCompletedEvent( + mock({ + statusCode: 401, + }), + ); + + expect(fido2ActiveRequestManager.removeActiveRequest).toHaveBeenCalled(); + }); + + it("triggers a closure of the inline menu if the subsequent request after the authentication is valid", async () => { + jest.useFakeTimers(); + + await initOverlayElementPorts(); + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + usePasskey: true, + portKey, + }); + triggerWebRequestOnCompletedEvent( + mock({ + statusCode: 200, + }), + ); + jest.advanceTimersByTime(3100); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "triggerDelayedAutofillInlineMenuClosure", + }); + }); + }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index c8d250df50..653d31ca52 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -55,7 +55,11 @@ import { MAX_SUB_FRAME_DEPTH, } from "../enums/autofill-overlay.enum"; import { AutofillService } from "../services/abstractions/autofill.service"; -import { generateRandomChars } from "../utils"; +import { + generateDomainMatchPatterns, + generateRandomChars, + isInvalidResponseStatusCode, +} from "../utils"; import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background"; import { @@ -151,7 +155,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { addEditCipherSubmitted: () => this.updateOverlayCiphers(), editedCipher: () => this.updateOverlayCiphers(), deletedCipher: () => this.updateOverlayCiphers(), - fido2AbortRequest: ({ sender }) => this.abortFido2ActiveRequest(sender), + fido2AbortRequest: ({ sender }) => this.abortFido2ActiveRequest(sender.tab.id), }; private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = { triggerDelayedAutofillInlineMenuClosure: () => this.triggerDelayedInlineMenuClosure(), @@ -672,10 +676,10 @@ export class OverlayBackground implements OverlayBackgroundInterface { /** * Aborts an active FIDO2 request for a given tab and updates the inline menu ciphers. * - * @param sender - The sender of the message + * @param tabId - The id of the tab to abort the request for */ - private async abortFido2ActiveRequest(sender: chrome.runtime.MessageSender) { - this.fido2ActiveRequestManager.removeActiveRequest(sender.tab.id); + private async abortFido2ActiveRequest(tabId: number) { + this.fido2ActiveRequestManager.removeActiveRequest(tabId); await this.updateOverlayCiphers(false); } @@ -939,11 +943,10 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (usePasskey && cipher.login?.hasFido2Credentials) { await this.authenticatePasskeyCredential( - sender.tab.id, + sender, cipher.login.fido2Credentials[0].credentialId, ); this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher); - this.closeInlineMenu(sender, { forceCloseInlineMenu: true }); return; } @@ -969,11 +972,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { /** * Triggers a FIDO2 authentication from the inline menu using the passed credential ID. * - * @param tabId - The tab ID to trigger the authentication for + * @param sender - The sender of the port message * @param credentialId - The credential ID to authenticate */ - async authenticatePasskeyCredential(tabId: number, credentialId: string) { - const request = this.fido2ActiveRequestManager.getActiveRequest(tabId); + async authenticatePasskeyCredential(sender: chrome.runtime.MessageSender, credentialId: string) { + const request = this.fido2ActiveRequestManager.getActiveRequest(sender.tab.id); if (!request) { this.logService.error( "Could not complete passkey autofill due to missing active Fido2 request", @@ -981,9 +984,35 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } + chrome.webRequest.onCompleted.addListener(this.handlePasskeyAuthenticationOnCompleted, { + urls: generateDomainMatchPatterns(sender.tab.url), + }); request.subject.next({ type: Fido2ActiveRequestEvents.Continue, credentialId }); } + /** + * Handles the next web request that occurs after a passkey authentication has been completed. + * Ensures that the inline menu closes after the request, and that the FIDO2 request is aborted + * if the request is not successful. + * + * @param details - The web request details + */ + private handlePasskeyAuthenticationOnCompleted = ( + details: chrome.webRequest.WebResponseCacheDetails, + ) => { + chrome.webRequest.onCompleted.removeListener(this.handlePasskeyAuthenticationOnCompleted); + + if (isInvalidResponseStatusCode(details.statusCode)) { + this.closeInlineMenu({ tab: { id: details.tabId } } as chrome.runtime.MessageSender, { + forceCloseInlineMenu: true, + }); + this.abortFido2ActiveRequest(details.tabId).catch((error) => this.logService.error(error)); + return; + } + + globalThis.setTimeout(() => this.triggerDelayedInlineMenuClosure(), 3000); + }; + /** * Sets the most recently used cipher at the top of the list of ciphers. * @@ -1587,6 +1616,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { passkeys: this.i18nService.translate("passkeys"), passwords: this.i18nService.translate("passwords"), logInWithPasskey: this.i18nService.translate("logInWithPasskeyAriaLabel"), + authenticating: this.i18nService.translate("authenticating"), }; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap index 3339781fab..0a4ae8d795 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap @@ -2131,6 +2131,44 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f `; +exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user fill cipher button event listeners filling a cipher displays an \`Authenticating\` loader when a passkey cipher is filled 1`] = ` +
+
+ +
+
+`; + exports[`AutofillInlineMenuList initAutofillInlineMenuList the locked inline menu for an unauthenticated user creates the views for the locked inline menu 1`] = `
{ postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey })); }); - it("allows the user to fill a cipher on click", () => { - const fillCipherButton = - autofillInlineMenuList["inlineMenuListContainer"].querySelector(".fill-cipher-button"); + describe("filling a cipher", () => { + it("allows the user to fill a cipher on click", () => { + const fillCipherButton = + autofillInlineMenuList["inlineMenuListContainer"].querySelector( + ".fill-cipher-button", + ); - fillCipherButton.dispatchEvent(new Event("click")); + fillCipherButton.dispatchEvent(new Event("click")); - expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { - command: "fillAutofillInlineMenuCipher", - inlineMenuCipherId: "1", - usePasskey: false, - portKey, - }, - "*", - ); + expect(globalThis.parent.postMessage).toHaveBeenCalledWith( + { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "1", + usePasskey: false, + portKey, + }, + "*", + ); + }); + + it("displays an `Authenticating` loader when a passkey cipher is filled", async () => { + postWindowMessage( + createInitAutofillInlineMenuListMessageMock({ + ciphers: [ + createAutofillOverlayCipherDataMock(1, { + name: "https://example.com", + login: { + username: "username1", + passkey: { + rpName: "https://example.com", + userName: "username1", + }, + }, + }), + ], + showPasskeysLabels: true, + portKey, + }), + ); + await flushPromises(); + + const fillCipherButton = + autofillInlineMenuList["inlineMenuListContainer"].querySelector( + ".fill-cipher-button", + ); + + fillCipherButton.dispatchEvent(new Event("click")); + + expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot(); + }); }); it("allows the user to move keyboard focus to the next cipher element on ArrowDown", () => { diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts index da8cbdd200..27a9c68d99 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts @@ -14,6 +14,7 @@ import { plusIcon, viewCipherIcon, passkeyIcon, + spinnerIcon, } from "../../../../utils/svg-icons"; import { AutofillInlineMenuListWindowMessageHandlers, @@ -40,6 +41,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private passkeysHeadingHeight: number; private lastPasskeysListItemHeight: number; private ciphersListHeight: number; + private isPasskeyAuthInProgress = false; private readonly showCiphersPerPage = 6; private readonly headingBorderClass = "inline-menu-list-heading--bordered"; private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers = @@ -156,15 +158,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { ciphers: InlineMenuCipherData[], showInlineMenuAccountCreation?: boolean, ) { + if (this.isPasskeyAuthInProgress) { + return; + } + this.ciphers = ciphers; this.currentCipherIndex = 0; this.showInlineMenuAccountCreation = showInlineMenuAccountCreation; - if (this.inlineMenuListContainer) { - this.inlineMenuListContainer.innerHTML = ""; - this.inlineMenuListContainer.classList.remove( - "inline-menu-list-container--with-new-item-button", - ); - } + this.resetInlineMenuContainer(); if (!ciphers?.length) { this.buildNoResultsInlineMenuList(); @@ -191,6 +192,18 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { this.newItemButtonElement.addEventListener(EVENTS.KEYUP, this.handleNewItemButtonKeyUpEvent); } + /** + * Clears and resets the inline menu list container. + */ + private resetInlineMenuContainer() { + if (this.inlineMenuListContainer) { + this.inlineMenuListContainer.innerHTML = ""; + this.inlineMenuListContainer.classList.remove( + "inline-menu-list-container--with-new-item-button", + ); + } + } + /** * Inline menu view that is presented when no ciphers are found for a given page. * Facilitates the ability to add a new vault item from the inline menu. @@ -330,7 +343,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { this.ciphersList.addEventListener( EVENTS.SCROLL, this.useEventHandlersMemo( - throttle(() => this.updatePasskeysHeadingsOnScroll(this.ciphersList.scrollTop), 50), + throttle(this.handleThrottledOnScrollEvent, 50), UPDATE_PASSKEYS_HEADINGS_ON_SCROLL, ), options, @@ -342,7 +355,10 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles updating the list of ciphers when the * user scrolls to the bottom of the list. */ - private updateCiphersListOnScroll = () => { + private updateCiphersListOnScroll = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (this.cipherListScrollIsDebounced) { return; } @@ -382,6 +398,18 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { } }; + /** + * Throttled handler for updating the passkeys and login headings when the user scrolls the ciphers list. + * + * @param event - The scroll event. + */ + private handleThrottledOnScrollEvent = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + this.updatePasskeysHeadingsOnScroll(this.ciphersList.scrollTop); + }; + /** * Updates the passkeys and login headings when the user scrolls the ciphers list. * @@ -596,16 +624,29 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => { const usePasskey = !!cipher.login?.passkey; return this.useEventHandlersMemo( - () => - this.postMessageToParent({ - command: "fillAutofillInlineMenuCipher", - inlineMenuCipherId: cipher.id, - usePasskey, - }), + () => this.triggerFillCipherClickEvent(cipher, usePasskey), `${cipher.id}-fill-cipher-button-click-handler-${usePasskey ? "passkey" : ""}`, ); }; + /** + * Triggers a fill of the currently selected cipher. + * + * @param cipher - The cipher to fill. + * @param usePasskey - Whether the cipher uses a passkey. + */ + private triggerFillCipherClickEvent = (cipher: InlineMenuCipherData, usePasskey: boolean) => { + if (usePasskey) { + this.createPasskeyAuthenticatingLoader(); + } + + this.postMessageToParent({ + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: cipher.id, + usePasskey, + }); + }; + /** * Handles the keyup event for the fill cipher button. Facilitates * selecting the next/previous cipher item on ArrowDown/ArrowUp. Also @@ -889,6 +930,26 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { return cipherDetailsElement; } + /** + * Creates an indicator for the user that the passkey is being authenticated. + */ + private createPasskeyAuthenticatingLoader() { + this.isPasskeyAuthInProgress = true; + this.resetInlineMenuContainer(); + + const passkeyAuthenticatingLoader = globalThis.document.createElement("div"); + passkeyAuthenticatingLoader.classList.add("passkey-authenticating-loader"); + passkeyAuthenticatingLoader.textContent = this.getTranslation("authenticating"); + passkeyAuthenticatingLoader.appendChild(buildSvgDomElement(spinnerIcon)); + + this.inlineMenuListContainer.appendChild(passkeyAuthenticatingLoader); + + globalThis.setTimeout(() => { + this.isPasskeyAuthInProgress = false; + this.postMessageToParent({ command: "checkAutofillInlineMenuButtonFocused" }); + }, 4000); + } + /** * Gets the subtitle text for a given cipher. * diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss index 9cd8ae1a73..fb12da78f4 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss @@ -15,6 +15,8 @@ body { width: 100%; padding: 0; margin: 0; + font-family: $font-family-sans-serif; + font-weight: 400; @include themify($themes) { color: themed("textColor"); @@ -23,8 +25,6 @@ body { } .inline-menu-list-message { - font-family: $font-family-sans-serif; - font-weight: 400; font-size: 1.4rem; line-height: 1.5; width: 100%; @@ -393,3 +393,38 @@ body { } } } + +@keyframes bwi-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(359deg); + } +} + +.passkey-authenticating-loader { + display: flex; + align-content: center; + align-items: center; + justify-content: center; + text-align: center; + padding: 1rem 0.8rem; + font-size: 1.4rem; + font-weight: 400; + + @include themify($themes) { + color: themed("passkeysAuthenticating"); + } + + svg { + animation: bwi-spin 2s infinite linear; + margin-left: 1rem; + + path { + @include themify($themes) { + fill: themed("passkeysAuthenticating") !important; + } + } + } +} diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 5d9bfa9f9d..cd49da7219 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -3017,9 +3017,11 @@ export default class AutofillService implements AutofillServiceInterface { const tabs = await BrowserApi.tabsQuery({}); for (let index = 0; index < tabs.length; index++) { const tab = tabs[index]; - if (tab.url?.startsWith("http")) { + if (tab?.id && tab.url?.startsWith("http")) { const frames = await BrowserApi.getAllFrameDetails(tab.id); - frames.forEach((frame) => this.injectAutofillScripts(tab, frame.frameId, false)); + if (frames) { + frames.forEach((frame) => this.injectAutofillScripts(tab, frame.frameId, false)); + } } } } diff --git a/apps/browser/src/autofill/shared/styles/variables.scss b/apps/browser/src/autofill/shared/styles/variables.scss index 40b0080bb0..bd75415a19 100644 --- a/apps/browser/src/autofill/shared/styles/variables.scss +++ b/apps/browser/src/autofill/shared/styles/variables.scss @@ -10,6 +10,7 @@ $border-color: #ced4dc; $border-color-dark: #ddd; $border-radius: 3px; $focus-outline-color: #1252a3; +$muted-blue: #5a6d91; $brand-primary: #175ddc; @@ -45,6 +46,7 @@ $themes: ( focusOutlineColor: $focus-outline-color, successColor: $success-color-light, errorColor: $error-color-light, + passkeysAuthenticating: $muted-blue, ), dark: ( textColor: #ffffff, @@ -60,6 +62,7 @@ $themes: ( focusOutlineColor: lighten($focus-outline-color, 25%), successColor: $success-color-dark, errorColor: $error-color-dark, + passkeysAuthenticating: #bac0ce, ), nord: ( textColor: $nord5, @@ -74,6 +77,7 @@ $themes: ( borderColor: $nord0, focusOutlineColor: lighten($focus-outline-color, 25%), successColor: $success-color-dark, + passkeysAuthenticating: $nord4, ), solarizedDark: ( textColor: $solarizedDarkBase2, @@ -89,6 +93,7 @@ $themes: ( borderColor: $solarizedDarkBase2, focusOutlineColor: lighten($focus-outline-color, 15%), successColor: $success-color-dark, + passkeysAuthenticating: $solarizedDarkBase2, ), ); diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 98c0a97ac5..f2fafac3d8 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -426,3 +426,50 @@ export function getSubmitButtonKeywordsSet(element: HTMLElement): Set { return keywordsSet; } + +/** + * Generates the origin and subdomain match patterns for the URL. + * + * @param url - The URL of the tab + */ +export function generateDomainMatchPatterns(url: string): string[] { + try { + const extensionUrlPattern = + /^(chrome|chrome-extension|moz-extension|safari-web-extension):\/\/\/?/; + if (extensionUrlPattern.test(url)) { + return []; + } + + // Add protocol to URL if it is missing to allow for parsing the hostname correctly + const urlPattern = /^(https?|file):\/\/\/?/; + if (!urlPattern.test(url)) { + url = `https://${url}`; + } + + let protocolGlob = "*://"; + if (url.startsWith("file:///")) { + protocolGlob = "*:///"; // File URLs require three slashes to be a valid match pattern + } + + const parsedUrl = new URL(url); + const originMatchPattern = `${protocolGlob}${parsedUrl.hostname}/*`; + + const splitHost = parsedUrl.hostname.split("."); + const domain = splitHost.slice(-2).join("."); + const subDomainMatchPattern = `${protocolGlob}*.${domain}/*`; + + return [originMatchPattern, subDomainMatchPattern]; + } catch { + return []; + } +} + +/** + * Determines if the status code of the web response is invalid. An invalid status code is + * any status code that is not in the 200-299 range. + * + * @param statusCode - The status code of the web response + */ +export function isInvalidResponseStatusCode(statusCode: number) { + return statusCode < 200 || statusCode >= 300; +} diff --git a/apps/browser/src/autofill/utils/svg-icons.ts b/apps/browser/src/autofill/utils/svg-icons.ts index df2cfa189f..908e57f2cb 100644 --- a/apps/browser/src/autofill/utils/svg-icons.ts +++ b/apps/browser/src/autofill/utils/svg-icons.ts @@ -27,3 +27,6 @@ export const passkeyIcon = export const circleCheckIcon = ''; + +export const spinnerIcon = + '';