mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-02 18:17:46 +01:00
[PM-12316] Implement inline menu passkeys authenticating state (#11113)
This commit is contained in:
parent
caece397c6
commit
077abe0518
@ -4496,5 +4496,8 @@
|
|||||||
},
|
},
|
||||||
"noEditPermissions": {
|
"noEditPermissions": {
|
||||||
"message": "You don't have permission to edit this item"
|
"message": "You don't have permission to edit this item"
|
||||||
|
},
|
||||||
|
"authenticating": {
|
||||||
|
"message": "Authenticating"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
|||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
|
||||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||||
|
import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ActiveFormSubmissionRequests,
|
ActiveFormSubmissionRequests,
|
||||||
@ -109,35 +110,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
|||||||
*/
|
*/
|
||||||
private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) {
|
private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) {
|
||||||
return new Set([
|
return new Set([
|
||||||
...this.generateMatchPatterns(sender.url),
|
...generateDomainMatchPatterns(sender.url),
|
||||||
...this.generateMatchPatterns(sender.tab.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
|
* 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.
|
* 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) => {
|
private handleOnCompletedRequestEvent = async (details: chrome.webRequest.WebResponseDetails) => {
|
||||||
if (
|
if (
|
||||||
this.requestHostIsInvalid(details) ||
|
this.requestHostIsInvalid(details) ||
|
||||||
this.isInvalidStatusCode(details.statusCode) ||
|
isInvalidResponseStatusCode(details.statusCode) ||
|
||||||
!this.activeFormSubmissionRequests.has(details.requestId)
|
!this.activeFormSubmissionRequests.has(details.requestId)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@ -472,16 +449,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
|||||||
this.setupWebRequestsListeners();
|
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
|
* 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.
|
* start with "http" or a tab id that is less than 0.
|
||||||
|
@ -61,6 +61,7 @@ import {
|
|||||||
triggerPortOnDisconnectEvent,
|
triggerPortOnDisconnectEvent,
|
||||||
triggerPortOnMessageEvent,
|
triggerPortOnMessageEvent,
|
||||||
triggerWebNavigationOnCommittedEvent,
|
triggerWebNavigationOnCommittedEvent,
|
||||||
|
triggerWebRequestOnCompletedEvent,
|
||||||
} from "../spec/testing-utils";
|
} from "../spec/testing-utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -3003,9 +3004,12 @@ describe("OverlayBackground", () => {
|
|||||||
expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code");
|
expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("triggers passkey authentication through mediated conditional UI", async () => {
|
describe("triggering passkey authentication", () => {
|
||||||
|
let cipher1: CipherView;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
const fido2Credential = mock<Fido2CredentialView>({ credentialId: "credential-id" });
|
const fido2Credential = mock<Fido2CredentialView>({ credentialId: "credential-id" });
|
||||||
const cipher1 = mock<CipherView>({
|
cipher1 = mock<CipherView>({
|
||||||
id: "inline-menu-cipher-1",
|
id: "inline-menu-cipher-1",
|
||||||
login: {
|
login: {
|
||||||
username: "username1",
|
username: "username1",
|
||||||
@ -3013,17 +3017,20 @@ describe("OverlayBackground", () => {
|
|||||||
fido2Credentials: [fido2Credential],
|
fido2Credentials: [fido2Credential],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
overlayBackground["inlineMenuCiphers"] = new Map([["inline-menu-cipher-1", cipher1]]);
|
|
||||||
const pageDetailsForTab = {
|
const pageDetailsForTab = {
|
||||||
frameId: sender.frameId,
|
frameId: sender.frameId,
|
||||||
tab: sender.tab,
|
tab: sender.tab,
|
||||||
details: pageDetails,
|
details: pageDetails,
|
||||||
};
|
};
|
||||||
|
overlayBackground["inlineMenuCiphers"] = new Map([["inline-menu-cipher-1", cipher1]]);
|
||||||
overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([
|
overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([
|
||||||
[sender.frameId, pageDetailsForTab],
|
[sender.frameId, pageDetailsForTab],
|
||||||
]);
|
]);
|
||||||
autofillService.isPasswordRepromptRequired.mockResolvedValue(false);
|
autofillService.isPasswordRepromptRequired.mockResolvedValue(false);
|
||||||
jest.spyOn(fido2ActiveRequestManager, "getActiveRequest");
|
});
|
||||||
|
|
||||||
|
it("logs an error if the authentication could not complete due to a missing FIDO2 request", async () => {
|
||||||
|
jest.spyOn(logService, "error");
|
||||||
|
|
||||||
sendPortMessage(listMessageConnectorSpy, {
|
sendPortMessage(listMessageConnectorSpy, {
|
||||||
command: "fillAutofillInlineMenuCipher",
|
command: "fillAutofillInlineMenuCipher",
|
||||||
@ -3033,7 +3040,59 @@ describe("OverlayBackground", () => {
|
|||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(fido2ActiveRequestManager.getActiveRequest).toHaveBeenCalledWith(sender.tab.id);
|
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<chrome.webRequest.WebResponseCacheDetails>({
|
||||||
|
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<chrome.webRequest.WebResponseCacheDetails>({
|
||||||
|
statusCode: 200,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
jest.advanceTimersByTime(3100);
|
||||||
|
|
||||||
|
expect(listPortSpy.postMessage).toHaveBeenCalledWith({
|
||||||
|
command: "triggerDelayedAutofillInlineMenuClosure",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -55,7 +55,11 @@ import {
|
|||||||
MAX_SUB_FRAME_DEPTH,
|
MAX_SUB_FRAME_DEPTH,
|
||||||
} from "../enums/autofill-overlay.enum";
|
} from "../enums/autofill-overlay.enum";
|
||||||
import { AutofillService } from "../services/abstractions/autofill.service";
|
import { AutofillService } from "../services/abstractions/autofill.service";
|
||||||
import { generateRandomChars } from "../utils";
|
import {
|
||||||
|
generateDomainMatchPatterns,
|
||||||
|
generateRandomChars,
|
||||||
|
isInvalidResponseStatusCode,
|
||||||
|
} from "../utils";
|
||||||
|
|
||||||
import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background";
|
import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background";
|
||||||
import {
|
import {
|
||||||
@ -151,7 +155,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
|||||||
addEditCipherSubmitted: () => this.updateOverlayCiphers(),
|
addEditCipherSubmitted: () => this.updateOverlayCiphers(),
|
||||||
editedCipher: () => this.updateOverlayCiphers(),
|
editedCipher: () => this.updateOverlayCiphers(),
|
||||||
deletedCipher: () => this.updateOverlayCiphers(),
|
deletedCipher: () => this.updateOverlayCiphers(),
|
||||||
fido2AbortRequest: ({ sender }) => this.abortFido2ActiveRequest(sender),
|
fido2AbortRequest: ({ sender }) => this.abortFido2ActiveRequest(sender.tab.id),
|
||||||
};
|
};
|
||||||
private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = {
|
private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = {
|
||||||
triggerDelayedAutofillInlineMenuClosure: () => this.triggerDelayedInlineMenuClosure(),
|
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.
|
* 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) {
|
private async abortFido2ActiveRequest(tabId: number) {
|
||||||
this.fido2ActiveRequestManager.removeActiveRequest(sender.tab.id);
|
this.fido2ActiveRequestManager.removeActiveRequest(tabId);
|
||||||
await this.updateOverlayCiphers(false);
|
await this.updateOverlayCiphers(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -939,11 +943,10 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
|||||||
|
|
||||||
if (usePasskey && cipher.login?.hasFido2Credentials) {
|
if (usePasskey && cipher.login?.hasFido2Credentials) {
|
||||||
await this.authenticatePasskeyCredential(
|
await this.authenticatePasskeyCredential(
|
||||||
sender.tab.id,
|
sender,
|
||||||
cipher.login.fido2Credentials[0].credentialId,
|
cipher.login.fido2Credentials[0].credentialId,
|
||||||
);
|
);
|
||||||
this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher);
|
this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher);
|
||||||
this.closeInlineMenu(sender, { forceCloseInlineMenu: true });
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -969,11 +972,11 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
|||||||
/**
|
/**
|
||||||
* Triggers a FIDO2 authentication from the inline menu using the passed credential ID.
|
* 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
|
* @param credentialId - The credential ID to authenticate
|
||||||
*/
|
*/
|
||||||
async authenticatePasskeyCredential(tabId: number, credentialId: string) {
|
async authenticatePasskeyCredential(sender: chrome.runtime.MessageSender, credentialId: string) {
|
||||||
const request = this.fido2ActiveRequestManager.getActiveRequest(tabId);
|
const request = this.fido2ActiveRequestManager.getActiveRequest(sender.tab.id);
|
||||||
if (!request) {
|
if (!request) {
|
||||||
this.logService.error(
|
this.logService.error(
|
||||||
"Could not complete passkey autofill due to missing active Fido2 request",
|
"Could not complete passkey autofill due to missing active Fido2 request",
|
||||||
@ -981,9 +984,35 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chrome.webRequest.onCompleted.addListener(this.handlePasskeyAuthenticationOnCompleted, {
|
||||||
|
urls: generateDomainMatchPatterns(sender.tab.url),
|
||||||
|
});
|
||||||
request.subject.next({ type: Fido2ActiveRequestEvents.Continue, credentialId });
|
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.
|
* 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"),
|
passkeys: this.i18nService.translate("passkeys"),
|
||||||
passwords: this.i18nService.translate("passwords"),
|
passwords: this.i18nService.translate("passwords"),
|
||||||
logInWithPasskey: this.i18nService.translate("logInWithPasskeyAriaLabel"),
|
logInWithPasskey: this.i18nService.translate("logInWithPasskeyAriaLabel"),
|
||||||
|
authenticating: this.i18nService.translate("authenticating"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2131,6 +2131,44 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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`] = `
|
||||||
|
<div
|
||||||
|
class="inline-menu-list-container theme_light"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="passkey-authenticating-loader"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
width="16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
clip-path="url(#a)"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4.869 15.015a.588.588 0 1 0 0-1.177.588.588 0 0 0 0 1.177ZM8.252 16a.588.588 0 1 0 0-1.176.588.588 0 0 0 0 1.176Zm3.683-.911a.589.589 0 1 0 0-1.177.589.589 0 0 0 0 1.177ZM2.43 12.882a.693.693 0 1 0 0-1.387.693.693 0 0 0 0 1.387ZM1.318 9.738a.82.82 0 1 0 0-1.64.82.82 0 0 0 0 1.64Zm.69-3.578a.968.968 0 1 0 0-1.937.968.968 0 0 0 0 1.937ZM4.81 3.337a1.175 1.175 0 1 0 0-2.35 1.175 1.175 0 0 0 0 2.35Zm4.597-.676a1.33 1.33 0 1 0 0-2.661 1.33 1.33 0 0 0 0 2.66Zm4.543 2.954a1.553 1.553 0 1 0 0-3.105 1.553 1.553 0 0 0 0 3.105Z"
|
||||||
|
fill="#5A6D91"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clippath
|
||||||
|
id="a"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0 0h16v16H0z"
|
||||||
|
fill="#fff"
|
||||||
|
/>
|
||||||
|
</clippath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`AutofillInlineMenuList initAutofillInlineMenuList the locked inline menu for an unauthenticated user creates the views for the locked inline menu 1`] = `
|
exports[`AutofillInlineMenuList initAutofillInlineMenuList the locked inline menu for an unauthenticated user creates the views for the locked inline menu 1`] = `
|
||||||
<div
|
<div
|
||||||
class="inline-menu-list-container theme_light"
|
class="inline-menu-list-container theme_light"
|
||||||
|
@ -230,9 +230,12 @@ describe("AutofillInlineMenuList", () => {
|
|||||||
postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey }));
|
postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("filling a cipher", () => {
|
||||||
it("allows the user to fill a cipher on click", () => {
|
it("allows the user to fill a cipher on click", () => {
|
||||||
const fillCipherButton =
|
const fillCipherButton =
|
||||||
autofillInlineMenuList["inlineMenuListContainer"].querySelector(".fill-cipher-button");
|
autofillInlineMenuList["inlineMenuListContainer"].querySelector(
|
||||||
|
".fill-cipher-button",
|
||||||
|
);
|
||||||
|
|
||||||
fillCipherButton.dispatchEvent(new Event("click"));
|
fillCipherButton.dispatchEvent(new Event("click"));
|
||||||
|
|
||||||
@ -247,6 +250,38 @@ describe("AutofillInlineMenuList", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
it("allows the user to move keyboard focus to the next cipher element on ArrowDown", () => {
|
||||||
const fillCipherElements =
|
const fillCipherElements =
|
||||||
autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll(
|
autofillInlineMenuList["inlineMenuListContainer"].querySelectorAll(
|
||||||
|
@ -14,6 +14,7 @@ import {
|
|||||||
plusIcon,
|
plusIcon,
|
||||||
viewCipherIcon,
|
viewCipherIcon,
|
||||||
passkeyIcon,
|
passkeyIcon,
|
||||||
|
spinnerIcon,
|
||||||
} from "../../../../utils/svg-icons";
|
} from "../../../../utils/svg-icons";
|
||||||
import {
|
import {
|
||||||
AutofillInlineMenuListWindowMessageHandlers,
|
AutofillInlineMenuListWindowMessageHandlers,
|
||||||
@ -40,6 +41,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
|||||||
private passkeysHeadingHeight: number;
|
private passkeysHeadingHeight: number;
|
||||||
private lastPasskeysListItemHeight: number;
|
private lastPasskeysListItemHeight: number;
|
||||||
private ciphersListHeight: number;
|
private ciphersListHeight: number;
|
||||||
|
private isPasskeyAuthInProgress = false;
|
||||||
private readonly showCiphersPerPage = 6;
|
private readonly showCiphersPerPage = 6;
|
||||||
private readonly headingBorderClass = "inline-menu-list-heading--bordered";
|
private readonly headingBorderClass = "inline-menu-list-heading--bordered";
|
||||||
private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers =
|
private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers =
|
||||||
@ -156,15 +158,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
|||||||
ciphers: InlineMenuCipherData[],
|
ciphers: InlineMenuCipherData[],
|
||||||
showInlineMenuAccountCreation?: boolean,
|
showInlineMenuAccountCreation?: boolean,
|
||||||
) {
|
) {
|
||||||
|
if (this.isPasskeyAuthInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.ciphers = ciphers;
|
this.ciphers = ciphers;
|
||||||
this.currentCipherIndex = 0;
|
this.currentCipherIndex = 0;
|
||||||
this.showInlineMenuAccountCreation = showInlineMenuAccountCreation;
|
this.showInlineMenuAccountCreation = showInlineMenuAccountCreation;
|
||||||
if (this.inlineMenuListContainer) {
|
this.resetInlineMenuContainer();
|
||||||
this.inlineMenuListContainer.innerHTML = "";
|
|
||||||
this.inlineMenuListContainer.classList.remove(
|
|
||||||
"inline-menu-list-container--with-new-item-button",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ciphers?.length) {
|
if (!ciphers?.length) {
|
||||||
this.buildNoResultsInlineMenuList();
|
this.buildNoResultsInlineMenuList();
|
||||||
@ -191,6 +192,18 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
|||||||
this.newItemButtonElement.addEventListener(EVENTS.KEYUP, this.handleNewItemButtonKeyUpEvent);
|
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.
|
* 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.
|
* 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(
|
this.ciphersList.addEventListener(
|
||||||
EVENTS.SCROLL,
|
EVENTS.SCROLL,
|
||||||
this.useEventHandlersMemo(
|
this.useEventHandlersMemo(
|
||||||
throttle(() => this.updatePasskeysHeadingsOnScroll(this.ciphersList.scrollTop), 50),
|
throttle(this.handleThrottledOnScrollEvent, 50),
|
||||||
UPDATE_PASSKEYS_HEADINGS_ON_SCROLL,
|
UPDATE_PASSKEYS_HEADINGS_ON_SCROLL,
|
||||||
),
|
),
|
||||||
options,
|
options,
|
||||||
@ -342,7 +355,10 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
|||||||
* Handles updating the list of ciphers when the
|
* Handles updating the list of ciphers when the
|
||||||
* user scrolls to the bottom of the list.
|
* user scrolls to the bottom of the list.
|
||||||
*/
|
*/
|
||||||
private updateCiphersListOnScroll = () => {
|
private updateCiphersListOnScroll = (event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
if (this.cipherListScrollIsDebounced) {
|
if (this.cipherListScrollIsDebounced) {
|
||||||
return;
|
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.
|
* Updates the passkeys and login headings when the user scrolls the ciphers list.
|
||||||
*
|
*
|
||||||
@ -596,14 +624,27 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
|||||||
private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => {
|
private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => {
|
||||||
const usePasskey = !!cipher.login?.passkey;
|
const usePasskey = !!cipher.login?.passkey;
|
||||||
return this.useEventHandlersMemo(
|
return this.useEventHandlersMemo(
|
||||||
() =>
|
() => 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({
|
this.postMessageToParent({
|
||||||
command: "fillAutofillInlineMenuCipher",
|
command: "fillAutofillInlineMenuCipher",
|
||||||
inlineMenuCipherId: cipher.id,
|
inlineMenuCipherId: cipher.id,
|
||||||
usePasskey,
|
usePasskey,
|
||||||
}),
|
});
|
||||||
`${cipher.id}-fill-cipher-button-click-handler-${usePasskey ? "passkey" : ""}`,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -889,6 +930,26 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
|||||||
return cipherDetailsElement;
|
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.
|
* Gets the subtitle text for a given cipher.
|
||||||
*
|
*
|
||||||
|
@ -15,6 +15,8 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: $font-family-sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
@include themify($themes) {
|
@include themify($themes) {
|
||||||
color: themed("textColor");
|
color: themed("textColor");
|
||||||
@ -23,8 +25,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inline-menu-list-message {
|
.inline-menu-list-message {
|
||||||
font-family: $font-family-sans-serif;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -3017,12 +3017,14 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
const tabs = await BrowserApi.tabsQuery({});
|
const tabs = await BrowserApi.tabsQuery({});
|
||||||
for (let index = 0; index < tabs.length; index++) {
|
for (let index = 0; index < tabs.length; index++) {
|
||||||
const tab = tabs[index];
|
const tab = tabs[index];
|
||||||
if (tab.url?.startsWith("http")) {
|
if (tab?.id && tab.url?.startsWith("http")) {
|
||||||
const frames = await BrowserApi.getAllFrameDetails(tab.id);
|
const frames = await BrowserApi.getAllFrameDetails(tab.id);
|
||||||
|
if (frames) {
|
||||||
frames.forEach((frame) => this.injectAutofillScripts(tab, frame.frameId, false));
|
frames.forEach((frame) => this.injectAutofillScripts(tab, frame.frameId, false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the autofill inline menu visibility setting in all active tabs
|
* Updates the autofill inline menu visibility setting in all active tabs
|
||||||
|
@ -10,6 +10,7 @@ $border-color: #ced4dc;
|
|||||||
$border-color-dark: #ddd;
|
$border-color-dark: #ddd;
|
||||||
$border-radius: 3px;
|
$border-radius: 3px;
|
||||||
$focus-outline-color: #1252a3;
|
$focus-outline-color: #1252a3;
|
||||||
|
$muted-blue: #5a6d91;
|
||||||
|
|
||||||
$brand-primary: #175ddc;
|
$brand-primary: #175ddc;
|
||||||
|
|
||||||
@ -45,6 +46,7 @@ $themes: (
|
|||||||
focusOutlineColor: $focus-outline-color,
|
focusOutlineColor: $focus-outline-color,
|
||||||
successColor: $success-color-light,
|
successColor: $success-color-light,
|
||||||
errorColor: $error-color-light,
|
errorColor: $error-color-light,
|
||||||
|
passkeysAuthenticating: $muted-blue,
|
||||||
),
|
),
|
||||||
dark: (
|
dark: (
|
||||||
textColor: #ffffff,
|
textColor: #ffffff,
|
||||||
@ -60,6 +62,7 @@ $themes: (
|
|||||||
focusOutlineColor: lighten($focus-outline-color, 25%),
|
focusOutlineColor: lighten($focus-outline-color, 25%),
|
||||||
successColor: $success-color-dark,
|
successColor: $success-color-dark,
|
||||||
errorColor: $error-color-dark,
|
errorColor: $error-color-dark,
|
||||||
|
passkeysAuthenticating: #bac0ce,
|
||||||
),
|
),
|
||||||
nord: (
|
nord: (
|
||||||
textColor: $nord5,
|
textColor: $nord5,
|
||||||
@ -74,6 +77,7 @@ $themes: (
|
|||||||
borderColor: $nord0,
|
borderColor: $nord0,
|
||||||
focusOutlineColor: lighten($focus-outline-color, 25%),
|
focusOutlineColor: lighten($focus-outline-color, 25%),
|
||||||
successColor: $success-color-dark,
|
successColor: $success-color-dark,
|
||||||
|
passkeysAuthenticating: $nord4,
|
||||||
),
|
),
|
||||||
solarizedDark: (
|
solarizedDark: (
|
||||||
textColor: $solarizedDarkBase2,
|
textColor: $solarizedDarkBase2,
|
||||||
@ -89,6 +93,7 @@ $themes: (
|
|||||||
borderColor: $solarizedDarkBase2,
|
borderColor: $solarizedDarkBase2,
|
||||||
focusOutlineColor: lighten($focus-outline-color, 15%),
|
focusOutlineColor: lighten($focus-outline-color, 15%),
|
||||||
successColor: $success-color-dark,
|
successColor: $success-color-dark,
|
||||||
|
passkeysAuthenticating: $solarizedDarkBase2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -426,3 +426,50 @@ export function getSubmitButtonKeywordsSet(element: HTMLElement): Set<string> {
|
|||||||
|
|
||||||
return keywordsSet;
|
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;
|
||||||
|
}
|
||||||
|
@ -27,3 +27,6 @@ export const passkeyIcon =
|
|||||||
|
|
||||||
export const circleCheckIcon =
|
export const circleCheckIcon =
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g clip-path="url(#a)"><path fill="#017E45" d="M8 15.5a8.383 8.383 0 0 1-4.445-1.264A7.627 7.627 0 0 1 .61 10.87a7.063 7.063 0 0 1-.455-4.333 7.368 7.368 0 0 1 2.19-3.84A8.181 8.181 0 0 1 6.438.644a8.498 8.498 0 0 1 4.623.427 7.912 7.912 0 0 1 3.59 2.762A7.171 7.171 0 0 1 16 8c-.002 1.988-.846 3.895-2.345 5.3-1.5 1.406-3.534 2.198-5.655 2.2ZM8 1.437a7.337 7.337 0 0 0-3.889 1.106 6.672 6.672 0 0 0-2.578 2.945 6.182 6.182 0 0 0-.399 3.792 6.448 6.448 0 0 0 1.916 3.36 7.156 7.156 0 0 0 3.584 1.796 7.434 7.434 0 0 0 4.044-.374 6.924 6.924 0 0 0 3.142-2.417A6.275 6.275 0 0 0 15 8c-.002-1.74-.74-3.407-2.053-4.638C11.635 2.131 9.856 1.44 8 1.437Zm-1.351 9.905a.361.361 0 0 1-.245-.094l-2.257-2.07a.326.326 0 0 1-.103-.232c0-.043.009-.085.027-.125a.334.334 0 0 1 .076-.107.366.366 0 0 1 .246-.097c.093 0 .182.033.249.093l1.843 1.687a.166.166 0 0 0 .126.044.17.17 0 0 0 .066-.018.157.157 0 0 0 .052-.041l4.623-5.636a.34.34 0 0 1 .102-.088.375.375 0 0 1 .27-.038.34.34 0 0 1 .216.156.311.311 0 0 1-.033.37L6.93 11.21a.344.344 0 0 1-.112.09.376.376 0 0 1-.141.039l-.03.003h.001Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 .5h16v15H0z"/></clipPath></defs></svg>';
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g clip-path="url(#a)"><path fill="#017E45" d="M8 15.5a8.383 8.383 0 0 1-4.445-1.264A7.627 7.627 0 0 1 .61 10.87a7.063 7.063 0 0 1-.455-4.333 7.368 7.368 0 0 1 2.19-3.84A8.181 8.181 0 0 1 6.438.644a8.498 8.498 0 0 1 4.623.427 7.912 7.912 0 0 1 3.59 2.762A7.171 7.171 0 0 1 16 8c-.002 1.988-.846 3.895-2.345 5.3-1.5 1.406-3.534 2.198-5.655 2.2ZM8 1.437a7.337 7.337 0 0 0-3.889 1.106 6.672 6.672 0 0 0-2.578 2.945 6.182 6.182 0 0 0-.399 3.792 6.448 6.448 0 0 0 1.916 3.36 7.156 7.156 0 0 0 3.584 1.796 7.434 7.434 0 0 0 4.044-.374 6.924 6.924 0 0 0 3.142-2.417A6.275 6.275 0 0 0 15 8c-.002-1.74-.74-3.407-2.053-4.638C11.635 2.131 9.856 1.44 8 1.437Zm-1.351 9.905a.361.361 0 0 1-.245-.094l-2.257-2.07a.326.326 0 0 1-.103-.232c0-.043.009-.085.027-.125a.334.334 0 0 1 .076-.107.366.366 0 0 1 .246-.097c.093 0 .182.033.249.093l1.843 1.687a.166.166 0 0 0 .126.044.17.17 0 0 0 .066-.018.157.157 0 0 0 .052-.041l4.623-5.636a.34.34 0 0 1 .102-.088.375.375 0 0 1 .27-.038.34.34 0 0 1 .216.156.311.311 0 0 1-.033.37L6.93 11.21a.344.344 0 0 1-.112.09.376.376 0 0 1-.141.039l-.03.003h.001Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 .5h16v15H0z"/></clipPath></defs></svg>';
|
||||||
|
|
||||||
|
export const spinnerIcon =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><g clip-path="url(#a)"><path fill="#5A6D91" d="M4.869 15.015a.588.588 0 1 0 0-1.177.588.588 0 0 0 0 1.177ZM8.252 16a.588.588 0 1 0 0-1.176.588.588 0 0 0 0 1.176Zm3.683-.911a.589.589 0 1 0 0-1.177.589.589 0 0 0 0 1.177ZM2.43 12.882a.693.693 0 1 0 0-1.387.693.693 0 0 0 0 1.387ZM1.318 9.738a.82.82 0 1 0 0-1.64.82.82 0 0 0 0 1.64Zm.69-3.578a.968.968 0 1 0 0-1.937.968.968 0 0 0 0 1.937ZM4.81 3.337a1.175 1.175 0 1 0 0-2.35 1.175 1.175 0 0 0 0 2.35Zm4.597-.676a1.33 1.33 0 1 0 0-2.661 1.33 1.33 0 0 0 0 2.66Zm4.543 2.954a1.553 1.553 0 1 0 0-3.105 1.553 1.553 0 0 0 0 3.105Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>';
|
||||||
|
Loading…
Reference in New Issue
Block a user