diff --git a/package.json b/package.json index ed31e3a45..c724027df 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "^web-push$": "/src/utils/__mocks__/web-push.ts", "^next/cache$": "/src/utils/__mocks__/next-cache.ts", "^@zerodev/sdk(.*)$": "/src/utils/__mocks__/zerodev-sdk.ts", + "^@simplewebauthn/browser$": "/src/utils/__mocks__/simplewebauthn-browser.ts", "^@/(.*)$": "/src/$1" }, "setupFilesAfterEnv": [ diff --git a/src/components/Global/UnsupportedBrowserModal/index.tsx b/src/components/Global/UnsupportedBrowserModal/index.tsx index a344920e7..a387025aa 100644 --- a/src/components/Global/UnsupportedBrowserModal/index.tsx +++ b/src/components/Global/UnsupportedBrowserModal/index.tsx @@ -4,9 +4,9 @@ import ActionModal, { type ActionModalButtonProps } from '@/components/Global/Ac import { useToast } from '@/components/0_Bruddle/Toast' import { type IconName } from '@/components/Global/Icons/Icon' import { copyTextToClipboardWithFallback } from '@/utils/general.utils' -import { useEffect, useState, Suspense } from 'react' +import { useEffect, useState, Suspense, useRef } from 'react' import { useSearchParams } from 'next/navigation' -import { usePasskeySupport } from '@/hooks/usePasskeySupport' +import { usePasskeySupportContext } from '@/context/passkeySupportContext' export const inAppSignatures = [ 'WebView', @@ -47,13 +47,23 @@ const UnsupportedBrowserModalContent = ({ const [showInAppBrowserModalViaDetection, setShowInAppBrowserModalViaDetection] = useState(false) const [copyButtonText, setCopyButtonText] = useState('Copy Link') const toast = useToast() - const { isSupported: isPasskeySupported } = usePasskeySupport() + const { isSupported: isPasskeySupported, isLoading: isLoadingPasskeySupport } = usePasskeySupportContext() + const copyTimeoutRef = useRef(null) useEffect(() => { - if (!isPasskeySupported) { + if (!isPasskeySupported && !isLoadingPasskeySupport) { setShowInAppBrowserModalViaDetection(true) } - }, [isPasskeySupported]) + }, [isPasskeySupported, isLoadingPasskeySupport]) + + // Cleanup timeout on unmount to prevent memory leak + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current) + } + } + }, []) if (!showInAppBrowserModalViaDetection && !visible) { return null @@ -72,6 +82,11 @@ const UnsupportedBrowserModalContent = ({ iconPosition: 'left', onClick: async () => { try { + // Clear any existing timeout to prevent multiple resets + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current) + } + // copy the redirect uri if it exists, otherwise copy the current url const redirectUri = searchParams.get('redirect_uri') const urlToCopy = redirectUri @@ -80,7 +95,7 @@ const UnsupportedBrowserModalContent = ({ await copyTextToClipboardWithFallback(urlToCopy) setCopyButtonText('Copied!') toast.success('Link copied to clipboard!') - setTimeout(() => setCopyButtonText('Copy Link'), 2000) + copyTimeoutRef.current = setTimeout(() => setCopyButtonText('Copy Link'), 2000) } catch (err) { console.error('Failed to copy: ', err) toast.error('Failed to copy link.') diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 116fef800..6bf3df974 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -15,6 +15,7 @@ import { useAuth } from '@/context/authContext' import { EInviteType } from '@/services/services.types' import { saveToCookie } from '@/utils' import { useLogin } from '@/hooks/useLogin' +import UnsupportedBrowserModal from '../Global/UnsupportedBrowserModal' function InvitePageContent() { const searchParams = useSearchParams() @@ -108,6 +109,7 @@ function InvitePageContent() { + ) } diff --git a/src/context/contextProvider.tsx b/src/context/contextProvider.tsx index 01dcb29c7..7cc96ea31 100644 --- a/src/context/contextProvider.tsx +++ b/src/context/contextProvider.tsx @@ -9,6 +9,7 @@ import { WithdrawFlowContextProvider } from './WithdrawFlowContext' import { ClaimBankFlowContextProvider } from './ClaimBankFlowContext' import { RequestFulfilmentFlowContextProvider } from './RequestFulfillmentFlowContext' import { SupportModalProvider } from './SupportModalContext' +import { PasskeySupportProvider } from './passkeySupportContext' export const ContextProvider = ({ children }: { children: React.ReactNode }) => { return ( @@ -22,7 +23,9 @@ export const ContextProvider = ({ children }: { children: React.ReactNode }) => - {children} + + {children} + diff --git a/src/context/passkeySupportContext.tsx b/src/context/passkeySupportContext.tsx new file mode 100644 index 000000000..110314b86 --- /dev/null +++ b/src/context/passkeySupportContext.tsx @@ -0,0 +1,16 @@ +'use client' +import { usePasskeySupport, type PasskeySupportResult } from '@/hooks/usePasskeySupport' +import { createContext, useContext } from 'react' + +const PasskeySupportContext = createContext(null) + +export const PasskeySupportProvider = ({ children }: { children: React.ReactNode }) => { + const support = usePasskeySupport() + return {children} +} + +export const usePasskeySupportContext = () => { + const context = useContext(PasskeySupportContext) + if (!context) throw new Error('usePasskeySupportContext must be used within PasskeySupportProvider') + return context +} diff --git a/src/utils/__mocks__/simplewebauthn-browser.ts b/src/utils/__mocks__/simplewebauthn-browser.ts new file mode 100644 index 000000000..18179aa0e --- /dev/null +++ b/src/utils/__mocks__/simplewebauthn-browser.ts @@ -0,0 +1,53 @@ +// Mock for @simplewebauthn/browser +export const browserSupportsWebAuthn = jest.fn(() => true) + +export const browserSupportsWebAuthnAutofill = jest.fn(() => Promise.resolve(true)) + +export const startRegistration = jest.fn(() => + Promise.resolve({ + id: 'mock-credential-id', + rawId: 'mock-raw-id', + response: { + clientDataJSON: 'mock-client-data', + attestationObject: 'mock-attestation', + }, + type: 'public-key', + }) +) + +export const startAuthentication = jest.fn(() => + Promise.resolve({ + id: 'mock-credential-id', + rawId: 'mock-raw-id', + response: { + clientDataJSON: 'mock-client-data', + authenticatorData: 'mock-auth-data', + signature: 'mock-signature', + userHandle: 'mock-user-handle', + }, + type: 'public-key', + }) +) + +export const platformAuthenticatorIsAvailable = jest.fn(() => Promise.resolve(true)) + +export const base64URLStringToBuffer = jest.fn((base64URLString: string) => { + return new ArrayBuffer(8) +}) + +export const bufferToBase64URLString = jest.fn((buffer: ArrayBuffer) => { + return 'mock-base64-string' +}) + +export class WebAuthnError extends Error { + constructor(message: string) { + super(message) + this.name = 'WebAuthnError' + } +} + +export class WebAuthnAbortService { + static createNewAbortSignal() { + return new AbortController().signal + } +}