diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index 3d4de791ad..c506cc4985 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -2522,6 +2522,14 @@
"message": "Send saved",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
+ "sendFilePopoutDialogText": {
+ "message": "Pop out extension?",
+ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
+ },
+ "sendFilePopoutDialogDesc": {
+ "message": "To create a file Send, you need to pop out te extension to a new window.",
+ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
+ },
"sendLinuxChromiumFileWarning": {
"message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner."
},
@@ -2531,6 +2539,9 @@
"sendSafariFileWarning": {
"message": "In order to choose a file using Safari, pop out to a new window by clicking this banner."
},
+ "popOut": {
+ "message": "Pop out"
+ },
"sendFileCalloutHeader": {
"message": "Before you start"
},
@@ -4503,5 +4514,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 =
+ '';
diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
index affa804cc7..4851541576 100644
--- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
+++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
@@ -3,7 +3,9 @@ import { Component, importProvidersFrom } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
+import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import {
AvatarModule,
BadgeModule,
@@ -318,6 +320,30 @@ export default {
});
},
},
+ {
+ provide: PolicyService,
+ useFactory: () => {
+ return {
+ policyAppliesToActiveUser$: () => {
+ return {
+ pipe: () => ({
+ subscribe: () => ({}),
+ }),
+ };
+ },
+ };
+ },
+ },
+ {
+ provide: SendService,
+ useFactory: () => {
+ return {
+ sends$: () => {
+ return { pipe: () => ({}) };
+ },
+ };
+ },
+ },
],
}),
applicationConfig({
diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts
index ced3f6462e..8463bbe6e9 100644
--- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts
+++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts
@@ -1,9 +1,41 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
+import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { RouterModule } from "@angular/router";
+import { filter, map, switchMap } from "rxjs";
+import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
+import { PolicyType } from "@bitwarden/common/admin-console/enums";
+import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { LinkModule } from "@bitwarden/components";
+const allNavButtons = [
+ {
+ label: "Vault",
+ page: "/tabs/vault",
+ iconKey: "lock",
+ iconKeyActive: "lock-f",
+ },
+ {
+ label: "Generator",
+ page: "/tabs/generator",
+ iconKey: "generate",
+ iconKeyActive: "generate-f",
+ },
+ {
+ label: "Send",
+ page: "/tabs/send",
+ iconKey: "send",
+ iconKeyActive: "send-f",
+ },
+ {
+ label: "Settings",
+ page: "/tabs/settings",
+ iconKey: "cog",
+ iconKeyActive: "cog-f",
+ },
+];
+
@Component({
selector: "popup-tab-navigation",
templateUrl: "popup-tab-navigation.component.html",
@@ -14,30 +46,23 @@ import { LinkModule } from "@bitwarden/components";
},
})
export class PopupTabNavigationComponent {
- navButtons = [
- {
- label: "Vault",
- page: "/tabs/vault",
- iconKey: "lock",
- iconKeyActive: "lock-f",
- },
- {
- label: "Generator",
- page: "/tabs/generator",
- iconKey: "generate",
- iconKeyActive: "generate-f",
- },
- {
- label: "Send",
- page: "/tabs/send",
- iconKey: "send",
- iconKeyActive: "send-f",
- },
- {
- label: "Settings",
- page: "/tabs/settings",
- iconKey: "cog",
- iconKeyActive: "cog-f",
- },
- ];
+ navButtons = allNavButtons;
+ constructor(
+ private policyService: PolicyService,
+ private sendService: SendService,
+ ) {
+ this.policyService
+ .policyAppliesToActiveUser$(PolicyType.DisableSend)
+ .pipe(
+ filter((policyAppliesToActiveUser) => policyAppliesToActiveUser),
+ switchMap(() => this.sendService.sends$),
+ map((sends) => sends.length > 1),
+ takeUntilDestroyed(),
+ )
+ .subscribe((hasSends) => {
+ this.navButtons = hasSends
+ ? allNavButtons
+ : allNavButtons.filter((b) => b.page !== "/tabs/send");
+ });
+ }
}
diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html
index 7f723cc736..e96a0742a0 100644
--- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html
+++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html
@@ -9,6 +9,8 @@
>
+
+