diff --git a/src/components/EmailLoginModal/EmailLoginModal.tsx b/src/components/EmailLoginModal/EmailLoginModal.tsx index 700ee9d8..53568be9 100644 --- a/src/components/EmailLoginModal/EmailLoginModal.tsx +++ b/src/components/EmailLoginModal/EmailLoginModal.tsx @@ -121,6 +121,7 @@ export const EmailLoginModal = (props: EmailLoginModalProps) => { try { // Verify OTP and connect wallet using thirdweb const account = await verifyOTPAndConnect(currentEmail, code) + console.log('[Thirdweb] OTP verified successfully!') // Store email for future reference localStorage.setItem('dcl_thirdweb_user_email', currentEmail) diff --git a/src/components/Pages/LoginPage/LoginPage.tsx b/src/components/Pages/LoginPage/LoginPage.tsx index 2073cc61..50a2c379 100644 --- a/src/components/Pages/LoginPage/LoginPage.tsx +++ b/src/components/Pages/LoginPage/LoginPage.tsx @@ -1,7 +1,9 @@ -import { useState, useCallback, useMemo, useEffect, useContext } from 'react' +import { useState, useCallback, useMemo, useEffect, useContext, useRef } from 'react' import classNames from 'classnames' +import type { AuthIdentity } from '@dcl/crypto' import { ChainId } from '@dcl/schemas/dist/dapps/chain-id' import { ProviderType } from '@dcl/schemas/dist/dapps/provider-type' +import { localStorageGetIdentity } from '@dcl/single-sign-on-client' import { Env } from '@dcl/ui-env' import { Loader } from 'decentraland-ui/dist/components/Loader/Loader' import { connection } from 'decentraland-connect' @@ -31,7 +33,9 @@ import { config } from '../../../modules/config' import { createAuthServerHttpClient } from '../../../shared/auth' import { isErrorWithName } from '../../../shared/errors' import { extractReferrerFromSearchParameters } from '../../../shared/locations' -import { sendEmailOTP } from '../../../shared/thirdweb' +import { disconnectWallet, sendEmailOTP } from '../../../shared/thirdweb' +import { authDebug, createAuthAttemptId } from '../../../shared/utils/authDebug' +import { AuthDebugDecision, AuthDebugEvent, AuthDebugStep } from '../../../shared/utils/authDebug.type' import { isClockSynchronized } from '../../../shared/utils/clockSync' import { handleError } from '../../../shared/utils/errorHandler' import { ClockSyncModal } from '../../ClockSyncModal' @@ -56,6 +60,11 @@ const BACKGROUND_IMAGES = [Image1, Image2, Image3, Image4, Image5, Image6, Image const NEW_USER_BACKGROUND_IMAGES = [ImageNew1, ImageNew2, ImageNew3, ImageNew4, ImageNew5, ImageNew6] const NEW_USER_PARAM_VARIANTS = ['newUser', 'newuser', 'new-user', 'new_user'] +type RunProfileRedirectOptions = { + onRedirect?: () => void + attemptId?: string | null +} + export const LoginPage = () => { const [isNewUser, setIsNewUser] = useState( NEW_USER_PARAM_VARIANTS.some(variant => new URLSearchParams(window.location.search).has(variant)) @@ -80,6 +89,7 @@ export const LoginPage = () => { const [confirmingLoginError, setConfirmingLoginError] = useState(null) // Store address from email login for clock sync continuation const [emailLoginAddress, setEmailLoginAddress] = useState(null) + const currentAuthAttemptIdRef = useRef(null) // TODO: remove /play from redirectTo. Build guest URL only when redirect path includes /play; use its presence to show the option. const guestRedirectToURL = useMemo(() => { @@ -105,6 +115,28 @@ export const LoginPage = () => { await trackGuestLogin() }, [trackGuestLogin]) + const getReferrerFromCurrentSearch = useCallback(() => { + const search = new URLSearchParams(window.location.search) + return extractReferrerFromSearchParameters(search) + }, []) + + const runProfileRedirect = useCallback( + async (account: string, referrer: string | null, identity: AuthIdentity | null = null, options: RunProfileRedirectOptions = {}) => { + const { onRedirect, attemptId = null } = options + await checkProfileAndRedirect( + account, + referrer, + () => { + redirect() + onRedirect?.() + }, + identity, + attemptId + ) + }, + [checkProfileAndRedirect, redirect] + ) + const checkClockSynchronization = useCallback(async (): Promise => { try { const httpClient = createAuthServerHttpClient() @@ -128,23 +160,76 @@ export const LoginPage = () => { // Handle email submit from the main page const handleEmailSubmit = useCallback( async (email: string) => { + const attemptId = createAuthAttemptId('email') + currentAuthAttemptIdRef.current = attemptId + setCurrentEmail(email) setIsEmailLoading(true) setEmailError(null) setCurrentConnectionType(ConnectionOptionType.EMAIL) + authDebug({ + event: AuthDebugEvent.LOGIN_ATTEMPT_STARTED, + attemptId, + providerType: ProviderType.THIRDWEB, + step: AuthDebugStep.LOGIN_PAGE, + decision: AuthDebugDecision.EMAIL + }) + authDebug({ + event: AuthDebugEvent.EMAIL_LOGIN_SUBMIT, + attemptId, + providerType: ProviderType.THIRDWEB, + step: AuthDebugStep.LOGIN_PAGE, + email + }) + trackLoginClick({ method: ConnectionOptionType.EMAIL, type: ConnectionType.WEB2 }) + // Avoid stale connection/account from a previous wallet session. + let cleanupDecision = AuthDebugDecision.SUCCESS + try { + await connection.disconnect() + await disconnectWallet() + } catch { + // Keep the flow going even if cleanup fails. + cleanupDecision = AuthDebugDecision.FAILED + } + authDebug({ + event: AuthDebugEvent.EMAIL_LOGIN_SESSION_CLEANUP, + attemptId, + providerType: ProviderType.THIRDWEB, + step: AuthDebugStep.LOGIN_PAGE, + decision: cleanupDecision + }) + try { // Send OTP to email await sendEmailOTP(email) + authDebug({ + event: AuthDebugEvent.EMAIL_LOGIN_OTP_REQUESTED, + attemptId, + providerType: ProviderType.THIRDWEB, + step: AuthDebugStep.LOGIN_PAGE, + decision: AuthDebugDecision.REQUESTED, + email + }) // Open OTP modal setShowEmailLoginModal(true) } catch (error) { const errorMessage = handleError(error, 'Error sending verification code') + authDebug({ + event: AuthDebugEvent.EMAIL_LOGIN_SUBMIT_FAILED, + attemptId, + providerType: ProviderType.THIRDWEB, + step: AuthDebugStep.LOGIN_PAGE, + decision: AuthDebugDecision.OTP_REQUEST_FAILED, + details: { + message: errorMessage + } + }) // Handle network errors with a user-friendly message if (errorMessage === 'Failed to fetch' || errorMessage?.toLowerCase().includes('network')) { setEmailError('Unable to connect. Please check your internet connection and try again.') @@ -181,7 +266,16 @@ export const LoginPage = () => { const isLoggingInThroughSocial = isSocialLogin(connectionType) const providerType = isLoggingInThroughSocial ? ConnectionType.WEB2 : ConnectionType.WEB3 + const providerName = fromConnectionOptionToProviderType(connectionType) + const attemptId = createAuthAttemptId(isLoggingInThroughSocial ? 'social' : 'wallet') setCurrentConnectionType(connectionType) + authDebug({ + event: AuthDebugEvent.LOGIN_ATTEMPT_STARTED, + attemptId, + providerType: providerName, + step: AuthDebugStep.LOGIN_PAGE, + decision: connectionType + }) trackLoginClick({ method: connectionType, @@ -208,15 +302,21 @@ export const LoginPage = () => { type: providerType }) - const search = new URLSearchParams(window.location.search) - const referrer = extractReferrerFromSearchParameters(search) + const referrer = getReferrerFromCurrentSearch() const isClockSync = await checkClockSynchronization() if (isClockSync) { - await checkProfileAndRedirect(connectionData.account ?? '', referrer, () => { - redirect() - setShowConnectionLayout(false) + authDebug({ + event: AuthDebugEvent.WALLET_LOGIN_PROFILE_CHECK, + attemptId, + account: connectionData.account ?? '', + providerType: providerName, + step: AuthDebugStep.LOGIN_PAGE + }) + await runProfileRedirect(connectionData.account ?? '', referrer, null, { + onRedirect: () => setShowConnectionLayout(false), + attemptId }) } } @@ -227,6 +327,16 @@ export const LoginPage = () => { connectionType } }) + authDebug({ + event: AuthDebugEvent.LOGIN_ATTEMPT_FAILED, + attemptId, + providerType: providerName, + step: AuthDebugStep.LOGIN_PAGE, + decision: AuthDebugDecision.ERROR, + details: { + message: error instanceof Error ? error.message : String(error) + } + }) if (isErrorWithName(error) && error.name === 'ErrorUnlockingWallet') { setLoadingState(ConnectionLayoutState.ERROR_LOCKED_WALLET) @@ -243,10 +353,10 @@ export const LoginPage = () => { flags[FeatureFlagsKeys.MAGIC_TEST], trackLoginClick, trackLoginSuccess, - checkProfileAndRedirect, - redirect, + runProfileRedirect, flagInitialized, checkClockSynchronization, + getReferrerFromCurrentSearch, isEmailOtpEnabled ] ) @@ -258,10 +368,12 @@ export const LoginPage = () => { }, [setShowConnectionLayout]) const handleEmailLoginClose = useCallback(() => { + currentAuthAttemptIdRef.current = null setShowEmailLoginModal(false) }, []) const handleEmailLoginBack = useCallback(() => { + currentAuthAttemptIdRef.current = null setShowEmailLoginModal(false) setCurrentEmail('') setCurrentConnectionType(undefined) @@ -273,33 +385,74 @@ export const LoginPage = () => { setShowConfirmingLogin(true) setConfirmingLoginError(null) + const attemptId = currentAuthAttemptIdRef.current ?? createAuthAttemptId('email') + currentAuthAttemptIdRef.current = attemptId + try { const { account } = result const address = account.address.toLowerCase() + const providerType = ProviderType.THIRDWEB + + authDebug({ + event: AuthDebugEvent.EMAIL_LOGIN_OTP_VERIFIED, + attemptId, + account: address, + providerType, + step: AuthDebugStep.LOGIN_PAGE + }) // Store address for clock sync continuation setEmailLoginAddress(address) // Generate identity using the thirdweb account's signMessage - await getIdentityWithSigner(address, async (message: string) => account.signMessage({ message })) + const freshIdentity = await getIdentityWithSigner(address, async (message: string) => account.signMessage({ message })) + authDebug({ + event: AuthDebugEvent.EMAIL_LOGIN_IDENTITY_READY, + attemptId, + account: address, + providerType, + step: AuthDebugStep.LOGIN_PAGE, + decision: freshIdentity ? AuthDebugDecision.READY : AuthDebugDecision.MISSING + }) - // Store connection data for marketplace to reconnect later - connection.storeConnectionData(ProviderType.THIRDWEB, ChainId.ETHEREUM_MAINNET) + // Ensure connector session matches the OTP-authenticated account. + const thirdwebConnection = await connection.connect(ProviderType.THIRDWEB, ChainId.ETHEREUM_MAINNET) + const connectedAddress = thirdwebConnection.account?.toLowerCase() ?? '' + if (!connectedAddress) { + throw new Error('Thirdweb connection did not return a connected account') + } + if (connectedAddress !== address) { + throw new Error('Detected a different account session than the verified email. Please try logging in again.') + } + authDebug({ + event: AuthDebugEvent.EMAIL_LOGIN_CONNECTOR_CONNECTED, + attemptId, + account: connectedAddress, + providerType, + step: AuthDebugStep.LOGIN_PAGE, + decision: AuthDebugDecision.SESSION_ALIGNED + }) await trackLoginSuccess({ ethAddress: address, type: ConnectionType.WEB2 }) - const search = new URLSearchParams(window.location.search) - const referrer = extractReferrerFromSearchParameters(search) + const referrer = getReferrerFromCurrentSearch() const isClockSync = await checkClockSynchronization() if (isClockSync) { - await checkProfileAndRedirect(address, referrer, () => { - redirect() - setShowConfirmingLogin(false) + authDebug({ + event: AuthDebugEvent.EMAIL_LOGIN_PROFILE_CHECK, + attemptId, + account: address, + providerType, + step: AuthDebugStep.LOGIN_PAGE + }) + await runProfileRedirect(address, referrer, freshIdentity, { + onRedirect: () => setShowConfirmingLogin(false), + attemptId }) } else { // Clock sync failed - hide confirming overlay so modal is visible @@ -311,10 +464,20 @@ export const LoginPage = () => { connectionType: ConnectionOptionType.EMAIL } }) + authDebug({ + event: AuthDebugEvent.EMAIL_LOGIN_FAILED, + attemptId, + providerType: ProviderType.THIRDWEB, + step: AuthDebugStep.LOGIN_PAGE, + decision: AuthDebugDecision.ERROR, + details: { + message: errorMessage + } + }) setConfirmingLoginError(errorMessage || 'Something went wrong. Please try again.') } }, - [trackLoginSuccess, checkClockSynchronization, checkProfileAndRedirect, redirect] + [trackLoginSuccess, checkClockSynchronization, runProfileRedirect, getReferrerFromCurrentSearch] ) const handleEmailLoginError = useCallback((error: string) => { @@ -324,6 +487,7 @@ export const LoginPage = () => { const handleConfirmingLoginRetry = useCallback(() => { setShowConfirmingLogin(false) setConfirmingLoginError(null) + currentAuthAttemptIdRef.current = null // Go back to the email login modal with the current email setShowEmailLoginModal(true) }, []) @@ -343,14 +507,22 @@ export const LoginPage = () => { const handleClockSyncContinue = useCallback(async () => { setShowClockSyncModal(false) - const search = new URLSearchParams(window.location.search) - const referrer = extractReferrerFromSearchParameters(search) + const referrer = getReferrerFromCurrentSearch() // Handle EMAIL login separately - use stored address instead of connectToProvider if (currentConnectionType === ConnectionOptionType.EMAIL && emailLoginAddress) { try { - await checkProfileAndRedirect(emailLoginAddress, referrer, () => { - redirect() + const freshIdentity = localStorageGetIdentity(emailLoginAddress) + authDebug({ + event: AuthDebugEvent.EMAIL_LOGIN_PROFILE_CHECK, + attemptId: currentAuthAttemptIdRef.current, + account: emailLoginAddress, + providerType: ProviderType.THIRDWEB, + step: AuthDebugStep.LOGIN_PAGE_CLOCK_SYNC, + decision: AuthDebugDecision.CONTINUE + }) + await runProfileRedirect(emailLoginAddress, referrer, freshIdentity, { + attemptId: currentAuthAttemptIdRef.current }) } catch (error) { handleError(error, 'Error during email clock sync continue flow') @@ -362,16 +534,22 @@ export const LoginPage = () => { if (currentConnectionType) { try { const connectionData = await connectToProvider(currentConnectionType) + authDebug({ + event: AuthDebugEvent.WALLET_LOGIN_PROFILE_CHECK, + account: connectionData.account ?? '', + providerType: fromConnectionOptionToProviderType(currentConnectionType), + step: AuthDebugStep.LOGIN_PAGE_CLOCK_SYNC, + decision: AuthDebugDecision.CONTINUE + }) - await checkProfileAndRedirect(connectionData.account ?? '', referrer, () => { - redirect() - setShowConnectionLayout(false) + await runProfileRedirect(connectionData.account ?? '', referrer, null, { + onRedirect: () => setShowConnectionLayout(false) }) } catch (error) { handleError(error, 'Error during clock sync continue flow') } } - }, [currentConnectionType, emailLoginAddress, checkProfileAndRedirect, redirect]) + }, [currentConnectionType, emailLoginAddress, runProfileRedirect, getReferrerFromCurrentSearch]) useEffect(() => { const backgroundInterval = setInterval(() => { diff --git a/src/hooks/redirection.ts b/src/hooks/redirection.ts index 4d6176dd..d469167a 100644 --- a/src/hooks/redirection.ts +++ b/src/hooks/redirection.ts @@ -2,6 +2,8 @@ import { useCallback } from 'react' import { useLocation } from 'react-router-dom' import { validateUrlInstance } from '@dcl/schemas' import { extractRedirectToFromSearchParameters, locations } from '../shared/locations' +import { authDebug } from '../shared/utils/authDebug' +import { AuthDebugDecision, AuthDebugEvent, AuthDebugStep } from '../shared/utils/authDebug.type' export const useAfterLoginRedirection = () => { const location = useLocation() @@ -86,6 +88,16 @@ export const useAfterLoginRedirection = () => { const isHostnameValid = hasOverrideUrl || finalUrlObj.hostname === window.location.hostname if (!isUrlValid || !isHostnameValid) { + authDebug({ + event: AuthDebugEvent.REDIRECT_FINAL_URL_INVALID, + step: AuthDebugStep.AFTER_LOGIN_REDIRECTION, + decision: AuthDebugDecision.HOME_FALLBACK, + details: { + finalUrlHostname: finalUrlObj.hostname, + isUrlValid, + isHostnameValid + } + }) console.error('Invalid final URL, redirecting to home') window.location.href = locations.home() return @@ -93,6 +105,14 @@ export const useAfterLoginRedirection = () => { window.location.href = finalUrl } catch (error) { + authDebug({ + event: AuthDebugEvent.REDIRECT_FINAL_URL_INVALID, + step: AuthDebugStep.AFTER_LOGIN_REDIRECTION, + decision: AuthDebugDecision.HOME_FALLBACK, + details: { + message: error instanceof Error ? error.message : String(error) + } + }) console.error('Final URL validation failed:', error) window.location.href = locations.home() } diff --git a/src/hooks/useAuthFlow.ts b/src/hooks/useAuthFlow.ts index b47fcc87..934dced9 100644 --- a/src/hooks/useAuthFlow.ts +++ b/src/hooks/useAuthFlow.ts @@ -10,6 +10,8 @@ import { useCurrentConnectionData } from '../shared/connection/hook' import { createFetcher } from '../shared/fetcher' import { locations } from '../shared/locations' import { isProfileComplete } from '../shared/profile' +import { authDebug } from '../shared/utils/authDebug' +import { AuthDebugDecision, AuthDebugEvent, AuthDebugStep } from '../shared/utils/authDebug.type' import { checkWebGpuSupport } from '../shared/utils/webgpu' import { useNavigateWithSearchParams } from './navigation' import { useAfterLoginRedirection } from './redirection' @@ -22,7 +24,7 @@ import { useDisabledCatalysts } from './useDisabledCatalysts' * and profile validation with redirects based on profile state and feature flags. * * @returns {{ - * checkProfileAndRedirect: (account: string, referrer: string | null, redirect: () => void, providedIdentity?: AuthIdentity | null) => Promise, + * checkProfileAndRedirect: (account: string, referrer: string | null, redirect: () => void, providedIdentity?: AuthIdentity | null, attemptId?: string | null) => Promise, * connectToMagic: () => Promise, * isInitialized: boolean * }} Authentication flow utilities and initialization status @@ -33,7 +35,7 @@ export const useAuthFlow = () => { const { flags, variants, initialized: flagInitialized } = useContext(FeatureFlagsContext) const [targetConfig] = useTargetConfig() - const { identity } = useCurrentConnectionData() + const { identity, providerType } = useCurrentConnectionData() const disabledCatalysts = useDisabledCatalysts() const { trackWebGPUSupportCheck } = useAnalytics() @@ -63,11 +65,34 @@ export const useAuthFlow = () => { * @param {() => void} redirect - Callback function to execute for successful redirect * @param {AuthIdentity | null} [providedIdentity] - Optional authentication identity to use for redeployment. * Falls back to hook identity if not provided. + * @param {string | null} [attemptId] - Optional auth attempt identifier for debugging. * @returns {Promise} A promise that resolves after the navigation or redirect completes */ const checkProfileAndRedirect = useCallback( - async (account: string, referrer: string | null, redirect: () => void, providedIdentity: AuthIdentity | null = null) => { + async ( + account: string, + referrer: string | null, + redirect: () => void, + providedIdentity: AuthIdentity | null = null, + attemptId: string | null = null + ) => { + authDebug({ + event: AuthDebugEvent.PROFILE_CHECK_STARTED, + attemptId, + account, + providerType: providerType ? String(providerType) : 'n/a', + step: AuthDebugStep.AUTH_FLOW + }) + if (!flagInitialized) { + authDebug({ + event: AuthDebugEvent.PROFILE_CHECK_DECISION, + attemptId, + account, + providerType: providerType ? String(providerType) : 'n/a', + step: AuthDebugStep.AUTH_FLOW, + decision: AuthDebugDecision.FLAGS_NOT_INITIALIZED + }) return undefined } @@ -78,6 +103,14 @@ export const useAuthFlow = () => { }) const consistencyResult = await fetchProfileWithConsistencyCheck(account, disabledCatalysts, fetcherWithTimeout) + authDebug({ + event: AuthDebugEvent.PROFILE_CONSISTENCY_CHECKED, + attemptId, + account, + providerType: providerType ? String(providerType) : 'n/a', + step: AuthDebugStep.AUTH_FLOW, + decision: consistencyResult.isConsistent ? AuthDebugDecision.CONSISTENT : AuthDebugDecision.INCONSISTENT + }) // Check A/B testing new onboarding flow const isFlowV2OnboardingFlowEnabled = variants[FeatureFlagsKeys.ONBOARDING_FLOW]?.name === OnboardingFlowVariant.V2 @@ -92,6 +125,14 @@ export const useAuthFlow = () => { try { await redeployExistingProfile(consistencyResult.profile, account, userIdentity, disabledCatalysts, fetcherWithTimeout) // If redeployment succeeds, continue with the login flow + authDebug({ + event: AuthDebugEvent.PROFILE_CHECK_DECISION, + attemptId, + account, + providerType: providerType ? String(providerType) : 'n/a', + step: AuthDebugStep.AUTH_FLOW, + decision: AuthDebugDecision.REDEPLOY_AND_REDIRECT + }) return redirect() } catch (error) { console.warn('Profile redeployment failed, attempting to redeploy with content server data:', error) @@ -105,6 +146,14 @@ export const useAuthFlow = () => { fetcherWithTimeout ) // If redeployment succeeds, continue with the login flow + authDebug({ + event: AuthDebugEvent.PROFILE_CHECK_DECISION, + attemptId, + account, + providerType: providerType ? String(providerType) : 'n/a', + step: AuthDebugStep.AUTH_FLOW, + decision: AuthDebugDecision.REDEPLOY_CONTENT_SERVER_AND_REDIRECT + }) return redirect() } catch (error) { console.warn('Profile redeployment failed, falling back to onboarding:', error) @@ -119,8 +168,24 @@ export const useAuthFlow = () => { const isAvatarSetupFlowAllowed = isFlowV2OnboardingFlowEnabled && hasWebGPU if (isAvatarSetupFlowAllowed) { + authDebug({ + event: AuthDebugEvent.PROFILE_CHECK_DECISION, + attemptId, + account, + providerType: providerType ? String(providerType) : 'n/a', + step: AuthDebugStep.AUTH_FLOW, + decision: AuthDebugDecision.AVATAR_SETUP + }) return navigate(locations.avatarSetup(redirectTo, referrer)) } else { + authDebug({ + event: AuthDebugEvent.PROFILE_CHECK_DECISION, + attemptId, + account, + providerType: providerType ? String(providerType) : 'n/a', + step: AuthDebugStep.AUTH_FLOW, + decision: AuthDebugDecision.SETUP + }) return navigate(locations.setup(redirectTo, referrer)) } } @@ -133,12 +198,37 @@ export const useAuthFlow = () => { const isProfileIncomplete = !profile || !isProfileComplete(profile) if (isProfileIncomplete && !isAvatarSetupFlowAllowed) { + authDebug({ + event: AuthDebugEvent.PROFILE_CHECK_DECISION, + attemptId, + account, + providerType: providerType ? String(providerType) : 'n/a', + step: AuthDebugStep.AUTH_FLOW, + decision: AuthDebugDecision.SETUP + }) return navigate(locations.setup(redirectTo, referrer)) } else if (isProfileIncomplete && isAvatarSetupFlowAllowed) { + authDebug({ + event: AuthDebugEvent.PROFILE_CHECK_DECISION, + attemptId, + account, + providerType: providerType ? String(providerType) : 'n/a', + step: AuthDebugStep.AUTH_FLOW, + decision: AuthDebugDecision.AVATAR_SETUP + }) return navigate(locations.avatarSetup(redirectTo, referrer)) } } + authDebug({ + event: AuthDebugEvent.PROFILE_CHECK_DECISION, + attemptId, + account, + providerType: providerType ? String(providerType) : 'n/a', + step: AuthDebugStep.AUTH_FLOW, + decision: AuthDebugDecision.REDIRECT + }) + redirect() }, [ @@ -148,6 +238,7 @@ export const useAuthFlow = () => { redirectTo, flagInitialized, identity, + providerType, disabledCatalysts ] ) diff --git a/src/shared/connection/connection.ts b/src/shared/connection/connection.ts index ed532df9..57958c7c 100644 --- a/src/shared/connection/connection.ts +++ b/src/shared/connection/connection.ts @@ -1,6 +1,8 @@ import { AuthIdentity } from '@dcl/crypto' import { localStorageGetIdentity } from '@dcl/single-sign-on-client' import { ConnectionResponse, connection } from 'decentraland-connect' +import { authDebug } from '../utils/authDebug' +import { AuthDebugDecision, AuthDebugEvent, AuthDebugStep } from '../utils/authDebug.type' export type DefinedConnectionResponse = Omit & { account: string } export type ConnectionData = DefinedConnectionResponse & { identity: AuthIdentity } @@ -12,24 +14,59 @@ export type ConnectionData = DefinedConnectionResponse & { identity: AuthIdentit * @returns The connection data or null if not connected. */ export const getCurrentConnectionData = async (): Promise => { + authDebug({ + event: AuthDebugEvent.CONNECTION_TRY_PREVIOUS_STARTED, + step: AuthDebugStep.GET_CURRENT_CONNECTION_DATA + }) + try { const previousConnection = await connection.tryPreviousConnection() + const providerType = previousConnection.providerType ? String(previousConnection.providerType) : 'n/a' if (!previousConnection.account) { + authDebug({ + event: AuthDebugEvent.CONNECTION_TRY_PREVIOUS_RESULT, + providerType, + step: AuthDebugStep.GET_CURRENT_CONNECTION_DATA, + decision: AuthDebugDecision.NO_ACCOUNT + }) return null } const identity = localStorageGetIdentity(previousConnection.account) if (!identity) { + authDebug({ + event: AuthDebugEvent.CONNECTION_TRY_PREVIOUS_RESULT, + account: previousConnection.account, + providerType, + step: AuthDebugStep.GET_CURRENT_CONNECTION_DATA, + decision: AuthDebugDecision.IDENTITY_MISSING + }) return null } + authDebug({ + event: AuthDebugEvent.CONNECTION_TRY_PREVIOUS_RESULT, + account: previousConnection.account, + providerType, + step: AuthDebugStep.GET_CURRENT_CONNECTION_DATA, + decision: AuthDebugDecision.CONNECTED + }) + return { ...previousConnection, account: previousConnection.account, identity } } catch (error) { + authDebug({ + event: AuthDebugEvent.CONNECTION_TRY_PREVIOUS_RESULT, + step: AuthDebugStep.GET_CURRENT_CONNECTION_DATA, + decision: AuthDebugDecision.ERROR, + details: { + message: error instanceof Error ? error.message : String(error) + } + }) return null } } diff --git a/src/shared/thirdweb/emailAuth.ts b/src/shared/thirdweb/emailAuth.ts index 5bb85a8c..a4328f17 100644 --- a/src/shared/thirdweb/emailAuth.ts +++ b/src/shared/thirdweb/emailAuth.ts @@ -1,4 +1,6 @@ import { inAppWallet, preAuthenticate } from 'thirdweb/wallets' +import { authDebug } from '../utils/authDebug' +import { AuthDebugDecision, AuthDebugEvent, AuthDebugStep } from '../utils/authDebug.type' import { getThirdwebClient } from './client' // Store the wallet instance for reuse @@ -78,8 +80,26 @@ export const verifyEmailOTPAndConnect = async (email: string, verificationCode: address: account.address, hasSignMessage: typeof account.signMessage === 'function' }) + authDebug({ + event: AuthDebugEvent.THIRDWEB_VERIFY_OTP, + account: account.address, + providerType: 'thirdweb', + step: AuthDebugStep.THIRDWEB_EMAIL_AUTH, + decision: AuthDebugDecision.SUCCESS, + email + }) return account } catch (error) { + authDebug({ + event: AuthDebugEvent.THIRDWEB_VERIFY_OTP, + providerType: 'thirdweb', + step: AuthDebugStep.THIRDWEB_EMAIL_AUTH, + decision: AuthDebugDecision.ERROR, + email, + details: { + message: error instanceof Error ? error.message : String(error) + } + }) console.error('[Thirdweb] Error verifying OTP:', error) throw error } diff --git a/src/shared/utils/authDebug.ts b/src/shared/utils/authDebug.ts new file mode 100644 index 00000000..ec36cced --- /dev/null +++ b/src/shared/utils/authDebug.ts @@ -0,0 +1,151 @@ +import { AuthDebugDetails, AuthDebugLog } from './authDebug.type' + +const AUTH_DEBUG_QUERY_PARAM = 'authDebug' +const AUTH_DEBUG_STORAGE_KEY = 'dcl_auth_debug' + +const MASKED_VALUE = '[REDACTED]' + +const isBrowser = () => typeof window !== 'undefined' + +const readAuthDebugQueryFlag = (): boolean => { + if (!isBrowser()) { + return false + } + + try { + const query = new URLSearchParams(window.location.search) + return query.get(AUTH_DEBUG_QUERY_PARAM) === '1' + } catch { + return false + } +} + +const readAuthDebugStorageFlag = (): boolean => { + if (!isBrowser()) { + return false + } + + try { + return window.localStorage.getItem(AUTH_DEBUG_STORAGE_KEY) === '1' + } catch { + return false + } +} + +const persistAuthDebugFlag = () => { + if (!isBrowser()) { + return + } + + try { + window.localStorage.setItem(AUTH_DEBUG_STORAGE_KEY, '1') + } catch { + // Ignore storage errors in private mode or restricted contexts. + } +} + +export const isAuthDebugEnabled = (): boolean => { + const isEnabledFromQuery = readAuthDebugQueryFlag() + + if (isEnabledFromQuery) { + persistAuthDebugFlag() + return true + } + + return readAuthDebugStorageFlag() +} + +export const createAuthAttemptId = (prefix = 'auth') => { + const timestamp = Date.now().toString(36) + const randomSuffix = Math.random().toString(36).slice(2, 8) + return `${prefix}-${timestamp}-${randomSuffix}` +} + +const maskAccount = (account?: string | null): string => { + if (!account) { + return 'n/a' + } + + if (account.length <= 10) { + return account + } + + return `${account.slice(0, 6)}...${account.slice(-4)}` +} + +const maskEmail = (email?: string | null): string => { + if (!email) { + return 'n/a' + } + + const [localPart, domain] = email.split('@') + if (!localPart || !domain) { + return MASKED_VALUE + } + + return `${localPart[0]}***@${domain}` +} + +const sanitizeValue = (key: string, value: unknown): unknown => { + if (value === null || value === undefined) { + return value + } + + const normalizedKey = key.toLowerCase() + + if (normalizedKey.includes('otp') || normalizedKey.includes('verificationcode') || normalizedKey === 'code') { + return MASKED_VALUE + } + + if (normalizedKey.includes('email')) { + return typeof value === 'string' ? maskEmail(value) : MASKED_VALUE + } + + if ( + normalizedKey.includes('account') || + normalizedKey.includes('address') || + normalizedKey.includes('wallet') || + normalizedKey.includes('eth') + ) { + return typeof value === 'string' ? maskAccount(value) : value + } + + if (Array.isArray(value)) { + return value.map((item, index) => sanitizeValue(`${key}_${index}`, item)) + } + + if (typeof value === 'object') { + const nested = value as Record + const sanitizedEntries = Object.entries(nested).map(([nestedKey, nestedValue]) => [nestedKey, sanitizeValue(nestedKey, nestedValue)]) + return Object.fromEntries(sanitizedEntries) + } + + return value +} + +const sanitizeDetails = (details?: AuthDebugDetails) => { + if (!details) { + return undefined + } + + return Object.fromEntries(Object.entries(details).map(([key, value]) => [key, sanitizeValue(key, value)])) +} + +export const authDebug = ({ event, attemptId, account, providerType, step, decision, email, details }: AuthDebugLog) => { + if (!isAuthDebugEnabled()) { + return + } + + const payload = { + event, + attemptId: attemptId ?? 'n/a', + account: maskAccount(account), + providerType: providerType ?? 'n/a', + step: step ?? 'n/a', + decision: decision ?? 'n/a', + ...(email ? { email: maskEmail(email) } : {}), + ...(details ? { details: sanitizeDetails(details) } : {}) + } + + console.log('[AuthDebug]', payload) +} diff --git a/src/shared/utils/authDebug.type.ts b/src/shared/utils/authDebug.type.ts new file mode 100644 index 00000000..93904528 --- /dev/null +++ b/src/shared/utils/authDebug.type.ts @@ -0,0 +1,82 @@ +export enum AuthDebugEvent { + CONNECTION_TRY_PREVIOUS_STARTED = 'connection_try_previous_started', + CONNECTION_TRY_PREVIOUS_RESULT = 'connection_try_previous_result', + LOGIN_ATTEMPT_STARTED = 'login_attempt_started', + EMAIL_LOGIN_SUBMIT = 'email_login_submit', + EMAIL_LOGIN_SESSION_CLEANUP = 'email_login_session_cleanup', + EMAIL_LOGIN_OTP_REQUESTED = 'email_login_otp_requested', + EMAIL_LOGIN_SUBMIT_FAILED = 'email_login_submit_failed', + WALLET_LOGIN_PROFILE_CHECK = 'wallet_login_profile_check', + LOGIN_ATTEMPT_FAILED = 'login_attempt_failed', + EMAIL_LOGIN_OTP_VERIFIED = 'email_login_otp_verified', + EMAIL_LOGIN_IDENTITY_READY = 'email_login_identity_ready', + EMAIL_LOGIN_CONNECTOR_CONNECTED = 'email_login_connector_connected', + EMAIL_LOGIN_PROFILE_CHECK = 'email_login_profile_check', + EMAIL_LOGIN_FAILED = 'email_login_failed', + REDIRECT_FINAL_URL_INVALID = 'redirect_final_url_invalid', + PROFILE_CHECK_STARTED = 'profile_check_started', + PROFILE_CONSISTENCY_CHECKED = 'profile_consistency_checked', + PROFILE_CHECK_DECISION = 'profile_check_decision', + THIRDWEB_VERIFY_OTP = 'thirdweb_verify_otp' +} + +export enum AuthDebugStep { + GET_CURRENT_CONNECTION_DATA = 'get-current-connection-data', + LOGIN_PAGE = 'login-page', + LOGIN_PAGE_CLOCK_SYNC = 'login-page-clock-sync', + AFTER_LOGIN_REDIRECTION = 'after-login-redirection', + AUTH_FLOW = 'auth-flow', + THIRDWEB_EMAIL_AUTH = 'thirdweb-email-auth' +} + +export enum AuthDebugDecision { + APPLE = 'apple', + AVATAR_SETUP = 'avatar-setup', + CONNECTED = 'connected', + CONSISTENT = 'consistent', + CONTINUE = 'continue', + COINBASE = 'coinbase', + DAPPER = 'dapper', + DISCORD = 'discord', + EMAIL = 'email', + ERROR = 'error', + FAILED = 'failed', + FLAGS_NOT_INITIALIZED = 'flags-not-initialized', + FORTMATIC = 'fortmatic', + GOOGLE = 'google', + HOME_FALLBACK = 'home-fallback', + IDENTITY_MISSING = 'identity-missing', + INCONSISTENT = 'inconsistent', + METAMASK = 'metamask', + METAMASK_MOBILE = 'metamask-mobile', + MISSING = 'missing', + NO_ACCOUNT = 'no-account', + OTP_REQUEST_FAILED = 'otp-request-failed', + READY = 'ready', + REDIRECT = 'redirect', + REDEPLOY_AND_REDIRECT = 'redeploy-and-redirect', + REDEPLOY_CONTENT_SERVER_AND_REDIRECT = 'redeploy-content-server-and-redirect', + REQUESTED = 'requested', + SAMSUNG_BLOCKCHAIN_WALLET = 'samsung-blockchain-wallet', + SESSION_ALIGNED = 'session-aligned', + SETUP = 'setup', + SUCCESS = 'success', + WALLET_CONNECT = 'wallet-connect', + WALLET_LINK = 'wallet-link', + X = 'x' +} + +type Primitive = string | number | boolean | null | undefined + +export type AuthDebugDetails = Record | unknown[]> + +export type AuthDebugLog = { + event: AuthDebugEvent + attemptId?: string | null + account?: string | null + providerType?: string | null + step?: AuthDebugStep | null + decision?: AuthDebugDecision | string | null + email?: string | null + details?: AuthDebugDetails +}