From ac9c3bed85a606f766f4c3e897d9783bc78b3f8b Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 8 Apr 2026 18:22:46 -0300 Subject: [PATCH 01/13] feat: optimize social auto-login redirect and add explorer deep link on callback When loginMethod is a social provider (Google, Discord, Apple, X), skip rendering the full login page UI and redirect to OAuth immediately via a lightweight component. On the callback page, when the OPEN_EXPLORER_AFTER_LOGIN feature flag is enabled, post the identity to the auth server and open the explorer via a deep link instead of redirecting to the redirectTo URL. --- .../FeatureFlagsProvider.types.ts | 3 +- .../Pages/CallbackPage/CallbackPage.spec.tsx | 309 ++++++++++++++++++ .../Pages/CallbackPage/CallbackPage.tsx | 31 +- .../CallbackPage/DesktopAuthSuccess.spec.tsx | 163 +++++++++ .../Pages/CallbackPage/DesktopAuthSuccess.tsx | 97 ++++++ .../Pages/LoginPage/LoginPage.spec.tsx | 4 + src/components/Pages/LoginPage/LoginPage.tsx | 15 +- .../SocialAutoLoginRedirect.spec.tsx | 114 +++++++ .../LoginPage/SocialAutoLoginRedirect.tsx | 55 ++++ src/hooks/useAutoLogin.ts | 2 +- src/modules/translations/en.json | 3 + 11 files changed, 791 insertions(+), 5 deletions(-) create mode 100644 src/components/Pages/CallbackPage/CallbackPage.spec.tsx create mode 100644 src/components/Pages/CallbackPage/DesktopAuthSuccess.spec.tsx create mode 100644 src/components/Pages/CallbackPage/DesktopAuthSuccess.tsx create mode 100644 src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx create mode 100644 src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx diff --git a/src/components/FeatureFlagsProvider/FeatureFlagsProvider.types.ts b/src/components/FeatureFlagsProvider/FeatureFlagsProvider.types.ts index 9a850b2e..7189f4ac 100644 --- a/src/components/FeatureFlagsProvider/FeatureFlagsProvider.types.ts +++ b/src/components/FeatureFlagsProvider/FeatureFlagsProvider.types.ts @@ -8,7 +8,8 @@ enum FeatureFlagsKeys { UNITY_WEARABLE_PREVIEW = 'dapps-unity-wearable-preview', ONBOARDING_FLOW = 'dapps-onboarding-flow', DISABLED_CATALYSTS = 'explorer-disabled-catalyst', - SIGN_IN_PRIMARY_OPTION = 'dapps-sign-in-primary-option' + SIGN_IN_PRIMARY_OPTION = 'dapps-sign-in-primary-option', + OPEN_EXPLORER_AFTER_LOGIN = 'dapps-open-explorer-after-login' } enum OnboardingFlowVariant { diff --git a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx new file mode 100644 index 00000000..c84a7ec7 --- /dev/null +++ b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx @@ -0,0 +1,309 @@ +/* eslint-disable @typescript-eslint/naming-convention, import/order -- mock shapes must match exported names */ +import { BrowserRouter } from 'react-router-dom' +import { render, waitFor } from '@testing-library/react' +import type { AuthIdentity } from '@dcl/crypto' +import { localStorageGetIdentity } from '@dcl/single-sign-on-client' +import { CallbackPage } from './CallbackPage' + +// --- Mocks --- + +const mockNavigate = jest.fn() +jest.mock('../../../hooks/navigation', () => ({ + useNavigateWithSearchParams: () => mockNavigate +})) + +const mockRedirect = jest.fn() +jest.mock('../../../hooks/redirection', () => ({ + useAfterLoginRedirection: () => ({ url: 'https://decentraland.org/', redirect: mockRedirect }) +})) + +const mockEnsureProfile = jest.fn() +jest.mock('../../../hooks/useEnsureProfile', () => ({ + useEnsureProfile: () => ({ ensureProfile: mockEnsureProfile }) +})) + +const mockTrackLoginSuccess = jest.fn().mockResolvedValue(undefined) +jest.mock('../../../hooks/useAnalytics', () => ({ + useAnalytics: () => ({ trackLoginSuccess: mockTrackLoginSuccess }) +})) + +jest.mock('../../../hooks/targetConfig', () => ({ + useTargetConfig: () => [ + { + skipSetup: true, + explorerText: 'Decentraland app', + connectionOptions: {} + }, + 'default' + ] +})) + +const mockGetIdentitySignature = jest.fn() +jest.mock('../../../shared/connection', () => ({ + useCurrentConnectionData: () => ({ + getIdentitySignature: mockGetIdentitySignature + }) +})) + +jest.mock('../../../shared/mobile', () => ({ + isMobileSession: () => false +})) + +const mockPostIdentity = jest.fn() +jest.mock('../../../shared/auth', () => ({ + createAuthServerHttpClient: () => ({ + postIdentity: mockPostIdentity + }) +})) + +const mockConnect = jest.fn() +jest.mock('decentraland-connect', () => ({ + connection: { + connect: (...args: unknown[]) => mockConnect(...args) + } +})) + +const mockGetRedirectResult = jest.fn() +jest.mock('../../../shared/utils/magicSdk', () => ({ + OAUTH_ACCESS_DENIED_ERROR: 'access_denied', + createMagicInstance: () => + Promise.resolve({ + oauth2: { getRedirectResult: mockGetRedirectResult } + }) +})) + +jest.mock('../../../shared/onboarding/markReturningUser', () => ({ + markReturningUser: jest.fn() +})) + +jest.mock('../../../shared/onboarding/getStoredEmail', () => ({ + getStoredEmail: jest.fn().mockReturnValue(null) +})) + +jest.mock('../../../shared/onboarding/trackCheckpoint', () => ({ + trackCheckpoint: jest.fn() +})) + +jest.mock('../../../shared/utils/errorHandler', () => ({ + handleError: jest.fn() +})) + +jest.mock('../../../shared/errors', () => ({ + isMagicExtensionError: jest.fn().mockReturnValue(false), + isMagicRpcError: jest.fn().mockReturnValue(false) +})) + +jest.mock('../../../shared/locations', () => ({ + extractReferrerFromSearchParameters: jest.fn().mockReturnValue(null), + locations: { + login: jest.fn().mockReturnValue('/auth/login'), + home: jest.fn().mockReturnValue('/') + } +})) + +jest.mock('@dcl/single-sign-on-client', () => ({ + localStorageGetIdentity: jest.fn() +})) + +jest.mock('../../AnimatedBackground', () => ({ + AnimatedBackground: () =>
+})) + +jest.mock('../../ConnectionModal/ConnectionLayout', () => ({ + ConnectionLayout: ({ state }: { state: string }) =>
+})) + +jest.mock('../../ConnectionModal/ConnectionLayout.type', () => ({ + ConnectionLayoutState: { + VALIDATING_SIGN_IN: 'validating_sign_in', + ERROR_GENERIC: 'error_generic' + } +})) + +jest.mock('./CallbackPage.styled', () => { + const Div = ({ children, ...props }: React.HTMLAttributes & { children?: React.ReactNode }) => ( +
{children}
+ ) + return { Container: Div, Wrapper: Div } +}) + +jest.mock('./DesktopAuthSuccess', () => ({ + DesktopAuthSuccess: ({ identityId }: { identityId: string }) =>
+})) + +jest.mock('../MobileCallbackPage/MobileCallbackPage', () => ({ + MobileCallbackPage: () =>
+})) + +jest.mock('../../FeatureFlagsProvider', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createContext } = require('react') + return { + FeatureFlagsContext: createContext({ + flags: {}, + variants: {}, + initialized: true + }), + FeatureFlagsKeys: { + MAGIC_TEST: 'dapps-magic-dev-test', + OPEN_EXPLORER_AFTER_LOGIN: 'dapps-open-explorer-after-login' + } + } +}) + +jest.mock('decentraland-ui2', () => ({ + CircularProgress: () => null +})) + +jest.mock('@dcl/schemas', () => ({ + ProviderType: { + MAGIC: 'magic', + MAGIC_TEST: 'magic_test' + } +})) + +// --- Helpers --- + +// Access the mocked FeatureFlagsContext to wrap renders +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { FeatureFlagsContext } = require('../../FeatureFlagsProvider') + +const createMockIdentity = (): AuthIdentity => + ({ + ephemeralIdentity: { + privateKey: '0x' + 'a'.repeat(64), + publicKey: '0x' + 'b'.repeat(130), + address: '0x' + 'c'.repeat(40) + }, + expiration: new Date(Date.now() + 60_000), + authChain: [] + }) as unknown as AuthIdentity + +const renderWithProviders = (flags: Record = {}) => { + return render( + + + + + + ) +} + +// --- Tests --- + +describe('CallbackPage', () => { + beforeEach(() => { + mockGetRedirectResult.mockResolvedValue({ oauth: { userInfo: {} } }) + mockConnect.mockResolvedValue({ + account: '0xTestAccount', + provider: {}, + providerType: 'magic' + }) + mockGetIdentitySignature.mockResolvedValue(createMockIdentity()) + mockTrackLoginSuccess.mockResolvedValue(undefined) + mockEnsureProfile.mockResolvedValue({ avatars: [{}] }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is enabled', () => { + const mockIdentity = createMockIdentity() + + beforeEach(() => { + ;(localStorageGetIdentity as jest.Mock).mockReturnValue(mockIdentity) + mockPostIdentity.mockResolvedValue({ identityId: 'test-identity-id' }) + }) + + it('should post the identity to the auth server', async () => { + renderWithProviders({ 'dapps-open-explorer-after-login': true }) + + await waitFor(() => { + expect(mockPostIdentity).toHaveBeenCalledWith(mockIdentity, { isMobile: false }) + }) + }) + + it('should render DesktopAuthSuccess with the identity id', async () => { + const { getByTestId } = renderWithProviders({ 'dapps-open-explorer-after-login': true }) + + await waitFor(() => { + const successEl = getByTestId('desktop-auth-success') + expect(successEl).toBeTruthy() + expect(successEl.getAttribute('data-identity-id')).toBe('test-identity-id') + }) + }) + + it('should not call redirect', async () => { + renderWithProviders({ 'dapps-open-explorer-after-login': true }) + + await waitFor(() => { + expect(mockPostIdentity).toHaveBeenCalled() + }) + + expect(mockRedirect).not.toHaveBeenCalled() + }) + }) + + describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is disabled', () => { + it('should not post the identity to the auth server', async () => { + renderWithProviders({}) + + await waitFor(() => { + expect(mockRedirect).toHaveBeenCalled() + }) + + expect(mockPostIdentity).not.toHaveBeenCalled() + }) + + it('should call redirect', async () => { + renderWithProviders({}) + + await waitFor(() => { + expect(mockRedirect).toHaveBeenCalled() + }) + }) + }) + + describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is enabled but identity is not in localStorage', () => { + beforeEach(() => { + ;(localStorageGetIdentity as jest.Mock).mockReturnValue(null) + }) + + it('should fall through to the normal redirect flow', async () => { + renderWithProviders({ 'dapps-open-explorer-after-login': true }) + + await waitFor(() => { + expect(mockRedirect).toHaveBeenCalled() + }) + + expect(mockPostIdentity).not.toHaveBeenCalled() + }) + }) + + describe('when the OAuth provider returns an access_denied error', () => { + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { search: '?error=access_denied', href: 'http://localhost/auth/callback?error=access_denied' }, + writable: true, + configurable: true + }) + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: { search: '', href: 'http://localhost/auth/callback' }, + writable: true, + configurable: true + }) + }) + + it('should navigate back to the login page', async () => { + renderWithProviders({}) + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/auth/login', { replace: true }) + }) + }) + }) +}) diff --git a/src/components/Pages/CallbackPage/CallbackPage.tsx b/src/components/Pages/CallbackPage/CallbackPage.tsx index 5bcc7507..bada84e4 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.tsx @@ -9,12 +9,13 @@ import { useTargetConfig } from '../../../hooks/targetConfig' import { useAnalytics } from '../../../hooks/useAnalytics' import { useEnsureProfile } from '../../../hooks/useEnsureProfile' import { ConnectionType } from '../../../modules/analytics/types' +import { createAuthServerHttpClient } from '../../../shared/auth' import { useCurrentConnectionData } from '../../../shared/connection' import { isMagicExtensionError, isMagicRpcError } from '../../../shared/errors' import { extractReferrerFromSearchParameters, locations } from '../../../shared/locations' import { isMobileSession } from '../../../shared/mobile' -import { markReturningUser } from '../../../shared/onboarding/markReturningUser' import { getStoredEmail } from '../../../shared/onboarding/getStoredEmail' +import { markReturningUser } from '../../../shared/onboarding/markReturningUser' import { trackCheckpoint } from '../../../shared/onboarding/trackCheckpoint' import { handleError } from '../../../shared/utils/errorHandler' import { OAUTH_ACCESS_DENIED_ERROR, createMagicInstance } from '../../../shared/utils/magicSdk' @@ -23,6 +24,7 @@ import { ConnectionLayout } from '../../ConnectionModal/ConnectionLayout' import { ConnectionLayoutState } from '../../ConnectionModal/ConnectionLayout.type' import { FeatureFlagsContext, FeatureFlagsKeys } from '../../FeatureFlagsProvider' import { MobileCallbackPage } from '../MobileCallbackPage/MobileCallbackPage' +import { DesktopAuthSuccess } from './DesktopAuthSuccess' import { Container, Wrapper } from './CallbackPage.styled' const CallbackPage = () => { @@ -41,6 +43,7 @@ const DesktopCallbackPage = () => { const [logInStarted, setLogInStarted] = useState(false) const [layoutState, setLayoutState] = useState(ConnectionLayoutState.VALIDATING_SIGN_IN) const [errorDetail, setErrorDetail] = useState(null) + const [identityId, setIdentityId] = useState(null) const { initialized, flags } = useContext(FeatureFlagsContext) const [targetConfig] = useTargetConfig() const { ensureProfile } = useEnsureProfile() @@ -101,6 +104,16 @@ const DesktopCallbackPage = () => { type: ConnectionType.WEB2 }) + if (flags[FeatureFlagsKeys.OPEN_EXPLORER_AFTER_LOGIN]) { + const freshIdentity = localStorageGetIdentity(ethAddress) + if (freshIdentity) { + const httpClient = createAuthServerHttpClient() + const response = await httpClient.postIdentity(freshIdentity, { isMobile: false }) + setIdentityId(response.identityId) + return + } + } + const account = connectionData.account ?? '' if (targetConfig && !targetConfig.skipSetup && account) { @@ -117,7 +130,17 @@ const DesktopCallbackPage = () => { navigate(locations.login(), { replace: true }) } }, - [navigate, connectAndGenerateSignature, redirect, trackLoginSuccess, initialized, targetConfig?.skipSetup, redirectTo, ensureProfile] + [ + navigate, + connectAndGenerateSignature, + redirect, + trackLoginSuccess, + initialized, + targetConfig?.skipSetup, + redirectTo, + ensureProfile, + flags[FeatureFlagsKeys.OPEN_EXPLORER_AFTER_LOGIN] + ] ) const logInAndRedirect = useCallback(async () => { @@ -190,6 +213,10 @@ const DesktopCallbackPage = () => { } }, [logInAndRedirect, initialized, logInStarted]) + if (identityId) { + return + } + return ( diff --git a/src/components/Pages/CallbackPage/DesktopAuthSuccess.spec.tsx b/src/components/Pages/CallbackPage/DesktopAuthSuccess.spec.tsx new file mode 100644 index 00000000..21b51af0 --- /dev/null +++ b/src/components/Pages/CallbackPage/DesktopAuthSuccess.spec.tsx @@ -0,0 +1,163 @@ +import { act, render, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { DesktopAuthSuccess } from './DesktopAuthSuccess' + +const mockLaunchDeepLink = jest.fn() +jest.mock('../RequestPage/utils', () => ({ + launchDeepLink: (...args: unknown[]) => mockLaunchDeepLink(...args) +})) + +jest.mock('../../../modules/config', () => ({ + config: { + get: (key: string) => { + if (key === 'ENVIRONMENT') return 'production' + return '' + } + } +})) + +jest.mock('@dcl/hooks', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (key === 'mobile_auth.redirect_countdown') return `Opening ${params?.explorerText} in ${params?.countdown}...` + if (key === 'mobile_auth.redirecting') return `Redirecting to ${params?.explorerText}...` + if (key === 'mobile_auth.could_not_open') return `Could not open ${params?.explorerText}` + if (key === 'mobile_auth.return_to') return `Open ${params?.explorerText}` + if (key === 'common.try_again') return 'Try again' + return key + } + }) +})) + +jest.mock('../../AnimatedBackground', () => ({ + AnimatedBackground: () =>
+})) + +jest.mock('./CallbackPage.styled', () => { + const Div = ({ children, ...props }: React.HTMLAttributes & { children?: React.ReactNode }) => ( +
{children}
+ ) + return { Container: Div, Wrapper: Div } +}) + +jest.mock('../../ConnectionModal/ConnectionLayout.styled', () => { + const Div = ({ children, ...props }: React.HTMLAttributes & { children?: React.ReactNode }) => ( +
{children}
+ ) + return { + ConnectionContainer: Div, + ConnectionTitle: Div, + DecentralandLogo: () =>
, + ErrorButtonContainer: Div, + ProgressContainer: Div + } +}) + +jest.mock('decentraland-ui2', () => ({ + Button: ({ + children, + onClick, + ...props + }: React.ButtonHTMLAttributes & { variant?: string; children?: React.ReactNode }) => ( + + ), + CircularProgress: () =>
+})) + +describe('DesktopAuthSuccess', () => { + const mockOnTryAgain = jest.fn() + + beforeEach(() => { + jest.useFakeTimers() + mockLaunchDeepLink.mockResolvedValue(true) + }) + + afterEach(() => { + jest.useRealTimers() + jest.clearAllMocks() + }) + + describe('when rendered with an identity id', () => { + it('should show a countdown message', () => { + const { container } = render( + + ) + + expect(container.textContent).toContain('Opening Decentraland app in 3...') + }) + + it('should show a button to open the explorer', () => { + const { getByTestId } = render( + + ) + + expect(getByTestId('desktop-auth-open-explorer-button')).toBeTruthy() + }) + + it('should attempt deep link after countdown', async () => { + render() + + act(() => { + jest.advanceTimersByTime(3000) + }) + + await waitFor(() => { + expect(mockLaunchDeepLink).toHaveBeenCalledWith('decentraland://?dclenv=org&signin=test-id-123') + }) + }) + }) + + describe('when deep link fails', () => { + beforeEach(() => { + mockLaunchDeepLink.mockResolvedValue(false) + }) + + it('should show a try again button', async () => { + const { getByTestId } = render( + + ) + + act(() => { + jest.advanceTimersByTime(3000) + }) + + await waitFor(() => { + expect(getByTestId('desktop-auth-try-again-button')).toBeTruthy() + }) + }) + + it('should call onTryAgain when the try again button is clicked', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + const { getByTestId } = render( + + ) + + act(() => { + jest.advanceTimersByTime(3000) + }) + + await waitFor(() => { + expect(getByTestId('desktop-auth-try-again-button')).toBeTruthy() + }) + + await user.click(getByTestId('desktop-auth-try-again-button')) + + expect(mockOnTryAgain).toHaveBeenCalled() + }) + }) + + describe('when the open explorer button is clicked', () => { + it('should attempt to launch the deep link', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + const { getByTestId } = render( + + ) + + await user.click(getByTestId('desktop-auth-open-explorer-button')) + + expect(mockLaunchDeepLink).toHaveBeenCalledWith('decentraland://?dclenv=org&signin=test-id-123') + }) + }) +}) diff --git a/src/components/Pages/CallbackPage/DesktopAuthSuccess.tsx b/src/components/Pages/CallbackPage/DesktopAuthSuccess.tsx new file mode 100644 index 00000000..5b75bd30 --- /dev/null +++ b/src/components/Pages/CallbackPage/DesktopAuthSuccess.tsx @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from '@dcl/hooks' +import { Button, CircularProgress } from 'decentraland-ui2' +import { config } from '../../../modules/config' +import { AnimatedBackground } from '../../AnimatedBackground' +import { + ConnectionContainer, + ConnectionTitle, + DecentralandLogo, + ErrorButtonContainer, + ProgressContainer +} from '../../ConnectionModal/ConnectionLayout.styled' +import { launchDeepLink } from '../RequestPage/utils' +import { Container, Wrapper } from './CallbackPage.styled' + +const COUNTDOWN_SECONDS = 3 + +const ENVIRONMENT_TO_DCLENV: Record = { + development: 'zone', + staging: 'today', + production: 'org' +} + +type Props = { + identityId: string + explorerText: string + onTryAgain: () => void +} + +export const DesktopAuthSuccess = ({ identityId, explorerText, onTryAgain }: Props) => { + const { t } = useTranslation() + const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS) + const [deepLinkFailed, setDeepLinkFailed] = useState(false) + + const environment = config.get('ENVIRONMENT').toLowerCase() + const dclenv = ENVIRONMENT_TO_DCLENV[environment] ?? 'org' + const deepLinkUrl = `decentraland://?dclenv=${dclenv}&signin=${identityId}` + + const attemptDeepLink = useCallback(async () => { + const wasLaunched = await launchDeepLink(deepLinkUrl) + if (!wasLaunched) { + setDeepLinkFailed(true) + } + }, [deepLinkUrl]) + + useEffect(() => { + if (deepLinkFailed) return + + const interval = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + clearInterval(interval) + attemptDeepLink() + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(interval) + }, [deepLinkFailed, attemptDeepLink]) + + return ( + + + + + + {deepLinkFailed ? ( + <> + {t('mobile_auth.could_not_open', { explorerText })} + + + + + ) : ( + <> + + {countdown > 0 + ? t('mobile_auth.redirect_countdown', { explorerText, countdown }) + : t('mobile_auth.redirecting', { explorerText })} + + + + + + + )} + + + + ) +} diff --git a/src/components/Pages/LoginPage/LoginPage.spec.tsx b/src/components/Pages/LoginPage/LoginPage.spec.tsx index 07c41f24..44f4ea23 100644 --- a/src/components/Pages/LoginPage/LoginPage.spec.tsx +++ b/src/components/Pages/LoginPage/LoginPage.spec.tsx @@ -151,6 +151,10 @@ jest.mock('./ConfirmingLogin', () => ({ ConfirmingLogin: () =>
})) +jest.mock('./SocialAutoLoginRedirect', () => ({ + SocialAutoLoginRedirect: () =>
+})) + jest.mock('decentraland-connect', () => ({ connection: { disconnect: jest.fn().mockResolvedValue(undefined) diff --git a/src/components/Pages/LoginPage/LoginPage.tsx b/src/components/Pages/LoginPage/LoginPage.tsx index 606c1ac7..e0959a18 100644 --- a/src/components/Pages/LoginPage/LoginPage.tsx +++ b/src/components/Pages/LoginPage/LoginPage.tsx @@ -18,7 +18,8 @@ import ImageNew6 from '../../../assets/images/background/image-new6.webp' import { useAfterLoginRedirection } from '../../../hooks/redirection' import { useTargetConfig } from '../../../hooks/targetConfig' import { useAnalytics } from '../../../hooks/useAnalytics' -import { useAutoLogin } from '../../../hooks/useAutoLogin' +import { VALID_LOGIN_METHODS, mapLoginMethodToConnectionOption, useAutoLogin } from '../../../hooks/useAutoLogin' +import type { LoginMethod } from '../../../hooks/useAutoLogin' import { useEnsureProfile } from '../../../hooks/useEnsureProfile' import { ConnectionType } from '../../../modules/analytics/types' import { createAuthServerHttpClient } from '../../../shared/auth' @@ -38,6 +39,7 @@ import { EmailLoginModal } from '../../EmailLoginModal' import { EmailLoginResult } from '../../EmailLoginModal/EmailLoginModal.types' import { FeatureFlagsContext, FeatureFlagsKeys } from '../../FeatureFlagsProvider' import { ConfirmingLogin } from './ConfirmingLogin' +import { SocialAutoLoginRedirect } from './SocialAutoLoginRedirect' import { connectToProvider, connectToSocialProvider, @@ -438,6 +440,17 @@ export const LoginPage = () => { ) } + // When loginMethod is a social provider, skip the full login UI and redirect immediately to OAuth. + // The full page (backgrounds, connection options, modals) is unnecessary since we just redirect away. + const loginMethodParam = new URLSearchParams(window.location.search).get('loginMethod')?.toLowerCase() + const socialAutoLoginType = + loginMethodParam && VALID_LOGIN_METHODS.includes(loginMethodParam as LoginMethod) + ? mapLoginMethodToConnectionOption(loginMethodParam as LoginMethod) + : null + if (socialAutoLoginType && isSocialLogin(socialAutoLoginType)) { + return + } + const backgroundImages = NEW_USER_BACKGROUND_IMAGES return (
diff --git a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx new file mode 100644 index 00000000..6ac9243c --- /dev/null +++ b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx @@ -0,0 +1,114 @@ +/* eslint-disable @typescript-eslint/naming-convention -- mock shapes must match exported names */ +import { render, waitFor } from '@testing-library/react' +import { ConnectionOptionType } from '../../Connection/Connection.types' +import { SocialAutoLoginRedirect } from './SocialAutoLoginRedirect' + +const mockConnectToSocialProvider = jest.fn() +jest.mock('./utils', () => ({ + connectToSocialProvider: (...args: unknown[]) => mockConnectToSocialProvider(...args) +})) + +const mockTrackLoginClick = jest.fn() +jest.mock('../../../hooks/useAnalytics', () => ({ + useAnalytics: () => ({ + trackLoginClick: mockTrackLoginClick + }) +})) + +jest.mock('../../../hooks/redirection', () => ({ + useAfterLoginRedirection: () => ({ url: 'https://decentraland.org/play', redirect: jest.fn() }) +})) + +jest.mock('@dcl/hooks', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (key === 'social_auto_login.redirecting_to') return `Redirecting to ${params?.provider}...` + return key + } + }) +})) + +jest.mock('../../FeatureFlagsProvider', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createContext } = require('react') + return { + FeatureFlagsContext: { + ...createContext({ + flags: { 'dapps-magic-dev-test': false }, + variants: {}, + initialized: true + }) + }, + FeatureFlagsKeys: { MAGIC_TEST: 'dapps-magic-dev-test' } + } +}) + +jest.mock('../../AnimatedBackground', () => ({ + AnimatedBackground: () =>
+})) + +jest.mock('../../ConnectionModal/ConnectionLayout.styled', () => { + const Div = ({ children, ...props }: React.HTMLAttributes & { children?: React.ReactNode }) => ( +
{children}
+ ) + return { + ConnectionContainer: Div, + ConnectionTitle: Div, + DecentralandLogo: () =>
, + ProgressContainer: Div + } +}) + +jest.mock('decentraland-ui2', () => ({ + CircularProgress: () =>
+})) + +describe('SocialAutoLoginRedirect', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe('when rendered with a social connection type', () => { + it('should render the animated background', () => { + const { getByTestId } = render() + expect(getByTestId('animated-background')).toBeTruthy() + }) + + it('should show a redirecting message with the provider name', () => { + const { container } = render() + expect(container.textContent).toContain('Redirecting to Google...') + }) + + it('should render a loading spinner', () => { + const { getByTestId } = render() + expect(getByTestId('loading-spinner')).toBeTruthy() + }) + + it('should track the login click', async () => { + render() + + await waitFor(() => { + expect(mockTrackLoginClick).toHaveBeenCalledWith({ + method: ConnectionOptionType.GOOGLE, + type: 'web2' + }) + }) + }) + + it('should call connectToSocialProvider with the correct arguments', async () => { + render() + + await waitFor(() => { + expect(mockConnectToSocialProvider).toHaveBeenCalledWith(ConnectionOptionType.GOOGLE, false, 'https://decentraland.org/play') + }) + }) + + it('should not call connectToSocialProvider twice', async () => { + render() + + await waitFor(() => { + expect(mockConnectToSocialProvider).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx new file mode 100644 index 00000000..1ce046be --- /dev/null +++ b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx @@ -0,0 +1,55 @@ +import { useCallback, useContext, useEffect, useRef } from 'react' +import { useTranslation } from '@dcl/hooks' +import { CircularProgress } from 'decentraland-ui2' +import { useAfterLoginRedirection } from '../../../hooks/redirection' +import { useAnalytics } from '../../../hooks/useAnalytics' +import { ConnectionType } from '../../../modules/analytics/types' +import { AnimatedBackground } from '../../AnimatedBackground' +import { ConnectionOptionType, connectionOptionTitles } from '../../Connection/Connection.types' +import { ConnectionContainer, ConnectionTitle, DecentralandLogo, ProgressContainer } from '../../ConnectionModal/ConnectionLayout.styled' +import { FeatureFlagsContext, FeatureFlagsKeys } from '../../FeatureFlagsProvider' +import { connectToSocialProvider } from './utils' + +type Props = { + connectionType: ConnectionOptionType +} + +export const SocialAutoLoginRedirect = ({ connectionType }: Props) => { + const { t } = useTranslation() + const { flags } = useContext(FeatureFlagsContext) + const { url: redirectTo } = useAfterLoginRedirection() + const { trackLoginClick } = useAnalytics() + const hasStarted = useRef(false) + + const startRedirect = useCallback(async () => { + trackLoginClick({ + method: connectionType, + type: ConnectionType.WEB2 + }) + + await connectToSocialProvider(connectionType, flags[FeatureFlagsKeys.MAGIC_TEST], redirectTo) + }, [connectionType, flags[FeatureFlagsKeys.MAGIC_TEST], redirectTo, trackLoginClick]) + + useEffect(() => { + if (hasStarted.current) return + hasStarted.current = true + startRedirect() + }, [startRedirect]) + + const providerName = connectionOptionTitles[connectionType] + + return ( +
+ +
+ + + {t('social_auto_login.redirecting_to', { provider: providerName })} + + + + +
+
+ ) +} diff --git a/src/hooks/useAutoLogin.ts b/src/hooks/useAutoLogin.ts index 737df428..22c09518 100644 --- a/src/hooks/useAutoLogin.ts +++ b/src/hooks/useAutoLogin.ts @@ -140,5 +140,5 @@ const useAutoLogin = ({ isReady, onConnect }: UseAutoLoginOptions): UseAutoLogin } } -export { useAutoLogin } +export { useAutoLogin, mapLoginMethodToConnectionOption, VALID_LOGIN_METHODS } export type { LoginMethod } diff --git a/src/modules/translations/en.json b/src/modules/translations/en.json index 720b46e6..4ff77240 100644 --- a/src/modules/translations/en.json +++ b/src/modules/translations/en.json @@ -236,6 +236,9 @@ "description": "The site you were redirected to is invalid.", "go_back": "Go back to the login page" }, + "social_auto_login": { + "redirecting_to": "Redirecting to {provider}..." + }, "mobile_auth": { "sign_in_successful": "Sign In Successful", "redirect_countdown": "You will be redirected to\n{explorerText} in {countdown}...", From 4ade6b6da768c472ca230fd605a8f027cae441b4 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 8 Apr 2026 20:35:26 -0300 Subject: [PATCH 02/13] fix: improve robustness of social auto-login and callback deep link flow - Add error handling to SocialAutoLoginRedirect: catch connectToSocialProvider failures and reload the page so the user sees the full login UI to retry - Replace inline styles with reusable Container/Wrapper styled components - Memoize loginMethod detection in LoginPage to avoid re-parsing on every render - Call markReturningUser before setting identityId in the deep link branch so the user isn't treated as new if the deep link fails - Log warning when identity is missing from localStorage despite the OPEN_EXPLORER_AFTER_LOGIN flag being enabled - Log warning when ENVIRONMENT config doesn't map to a known dclenv value --- .../Pages/CallbackPage/CallbackPage.spec.tsx | 11 +++++- .../Pages/CallbackPage/CallbackPage.tsx | 2 ++ .../Pages/CallbackPage/DesktopAuthSuccess.tsx | 7 ++-- src/components/Pages/LoginPage/LoginPage.tsx | 13 +++---- .../SocialAutoLoginRedirect.spec.tsx | 33 +++++++++++++++++ .../LoginPage/SocialAutoLoginRedirect.tsx | 35 +++++++++++++------ 6 files changed, 82 insertions(+), 19 deletions(-) diff --git a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx index c84a7ec7..d6741d3c 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx @@ -72,8 +72,9 @@ jest.mock('../../../shared/utils/magicSdk', () => ({ }) })) +const mockMarkReturningUser = jest.fn() jest.mock('../../../shared/onboarding/markReturningUser', () => ({ - markReturningUser: jest.fn() + markReturningUser: (...args: unknown[]) => mockMarkReturningUser(...args) })) jest.mock('../../../shared/onboarding/getStoredEmail', () => ({ @@ -243,6 +244,14 @@ describe('CallbackPage', () => { expect(mockRedirect).not.toHaveBeenCalled() }) + + it('should mark the user as returning', async () => { + renderWithProviders({ 'dapps-open-explorer-after-login': true }) + + await waitFor(() => { + expect(mockMarkReturningUser).toHaveBeenCalledWith('0xTestAccount') + }) + }) }) describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is disabled', () => { diff --git a/src/components/Pages/CallbackPage/CallbackPage.tsx b/src/components/Pages/CallbackPage/CallbackPage.tsx index bada84e4..19d0cad9 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.tsx @@ -109,9 +109,11 @@ const DesktopCallbackPage = () => { if (freshIdentity) { const httpClient = createAuthServerHttpClient() const response = await httpClient.postIdentity(freshIdentity, { isMobile: false }) + markReturningUser(connectionData.account ?? '') setIdentityId(response.identityId) return } + console.warn('OPEN_EXPLORER_AFTER_LOGIN enabled but identity not found in localStorage for', ethAddress) } const account = connectionData.account ?? '' diff --git a/src/components/Pages/CallbackPage/DesktopAuthSuccess.tsx b/src/components/Pages/CallbackPage/DesktopAuthSuccess.tsx index 5b75bd30..83209261 100644 --- a/src/components/Pages/CallbackPage/DesktopAuthSuccess.tsx +++ b/src/components/Pages/CallbackPage/DesktopAuthSuccess.tsx @@ -33,8 +33,11 @@ export const DesktopAuthSuccess = ({ identityId, explorerText, onTryAgain }: Pro const [deepLinkFailed, setDeepLinkFailed] = useState(false) const environment = config.get('ENVIRONMENT').toLowerCase() - const dclenv = ENVIRONMENT_TO_DCLENV[environment] ?? 'org' - const deepLinkUrl = `decentraland://?dclenv=${dclenv}&signin=${identityId}` + const dclenv = ENVIRONMENT_TO_DCLENV[environment] + if (!dclenv) { + console.warn('Unknown ENVIRONMENT value for deep link:', environment, '— defaulting to org') + } + const deepLinkUrl = `decentraland://?dclenv=${dclenv ?? 'org'}&signin=${identityId}` const attemptDeepLink = useCallback(async () => { const wasLaunched = await launchDeepLink(deepLinkUrl) diff --git a/src/components/Pages/LoginPage/LoginPage.tsx b/src/components/Pages/LoginPage/LoginPage.tsx index e0959a18..50129d62 100644 --- a/src/components/Pages/LoginPage/LoginPage.tsx +++ b/src/components/Pages/LoginPage/LoginPage.tsx @@ -442,12 +442,13 @@ export const LoginPage = () => { // When loginMethod is a social provider, skip the full login UI and redirect immediately to OAuth. // The full page (backgrounds, connection options, modals) is unnecessary since we just redirect away. - const loginMethodParam = new URLSearchParams(window.location.search).get('loginMethod')?.toLowerCase() - const socialAutoLoginType = - loginMethodParam && VALID_LOGIN_METHODS.includes(loginMethodParam as LoginMethod) - ? mapLoginMethodToConnectionOption(loginMethodParam as LoginMethod) - : null - if (socialAutoLoginType && isSocialLogin(socialAutoLoginType)) { + const socialAutoLoginType = useMemo(() => { + const param = new URLSearchParams(window.location.search).get('loginMethod')?.toLowerCase() + if (!param || !VALID_LOGIN_METHODS.includes(param as LoginMethod)) return null + const connectionOption = mapLoginMethodToConnectionOption(param as LoginMethod) + return isSocialLogin(connectionOption) ? connectionOption : null + }, []) + if (socialAutoLoginType) { return } diff --git a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx index 6ac9243c..117e4770 100644 --- a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx +++ b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx @@ -8,6 +8,10 @@ jest.mock('./utils', () => ({ connectToSocialProvider: (...args: unknown[]) => mockConnectToSocialProvider(...args) })) +jest.mock('../../../shared/utils/errorHandler', () => ({ + handleError: jest.fn() +})) + const mockTrackLoginClick = jest.fn() jest.mock('../../../hooks/useAnalytics', () => ({ useAnalytics: () => ({ @@ -43,6 +47,13 @@ jest.mock('../../FeatureFlagsProvider', () => { } }) +jest.mock('../../Pages/CallbackPage/CallbackPage.styled', () => { + const Div = ({ children, ...props }: React.HTMLAttributes & { children?: React.ReactNode }) => ( +
{children}
+ ) + return { Container: Div, Wrapper: Div } +}) + jest.mock('../../AnimatedBackground', () => ({ AnimatedBackground: () =>
})) @@ -111,4 +122,26 @@ describe('SocialAutoLoginRedirect', () => { }) }) }) + + describe('when connectToSocialProvider throws an error', () => { + let reloadMock: jest.Mock + + beforeEach(() => { + mockConnectToSocialProvider.mockRejectedValue(new Error('Magic SDK failed')) + reloadMock = jest.fn() + Object.defineProperty(window, 'location', { + value: { ...window.location, reload: reloadMock }, + writable: true, + configurable: true + }) + }) + + it('should reload the page to show the full login UI', async () => { + render() + + await waitFor(() => { + expect(reloadMock).toHaveBeenCalled() + }) + }) + }) }) diff --git a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx index 1ce046be..32b30f76 100644 --- a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx +++ b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx @@ -1,13 +1,15 @@ -import { useCallback, useContext, useEffect, useRef } from 'react' +import { useCallback, useContext, useEffect, useRef, useState } from 'react' import { useTranslation } from '@dcl/hooks' import { CircularProgress } from 'decentraland-ui2' import { useAfterLoginRedirection } from '../../../hooks/redirection' import { useAnalytics } from '../../../hooks/useAnalytics' import { ConnectionType } from '../../../modules/analytics/types' +import { handleError } from '../../../shared/utils/errorHandler' import { AnimatedBackground } from '../../AnimatedBackground' import { ConnectionOptionType, connectionOptionTitles } from '../../Connection/Connection.types' import { ConnectionContainer, ConnectionTitle, DecentralandLogo, ProgressContainer } from '../../ConnectionModal/ConnectionLayout.styled' import { FeatureFlagsContext, FeatureFlagsKeys } from '../../FeatureFlagsProvider' +import { Container, Wrapper } from '../../Pages/CallbackPage/CallbackPage.styled' import { connectToSocialProvider } from './utils' type Props = { @@ -20,14 +22,20 @@ export const SocialAutoLoginRedirect = ({ connectionType }: Props) => { const { url: redirectTo } = useAfterLoginRedirection() const { trackLoginClick } = useAnalytics() const hasStarted = useRef(false) + const [failed, setFailed] = useState(false) const startRedirect = useCallback(async () => { - trackLoginClick({ - method: connectionType, - type: ConnectionType.WEB2 - }) + try { + trackLoginClick({ + method: connectionType, + type: ConnectionType.WEB2 + }) - await connectToSocialProvider(connectionType, flags[FeatureFlagsKeys.MAGIC_TEST], redirectTo) + await connectToSocialProvider(connectionType, flags[FeatureFlagsKeys.MAGIC_TEST], redirectTo) + } catch (error) { + handleError(error, 'Error during social auto-login redirect') + setFailed(true) + } }, [connectionType, flags[FeatureFlagsKeys.MAGIC_TEST], redirectTo, trackLoginClick]) useEffect(() => { @@ -36,12 +44,19 @@ export const SocialAutoLoginRedirect = ({ connectionType }: Props) => { startRedirect() }, [startRedirect]) + // On failure, reload to show the full login page so the user can retry or pick another method + useEffect(() => { + if (failed) { + window.location.reload() + } + }, [failed]) + const providerName = connectionOptionTitles[connectionType] return ( -
+ -
+ {t('social_auto_login.redirecting_to', { provider: providerName })} @@ -49,7 +64,7 @@ export const SocialAutoLoginRedirect = ({ connectionType }: Props) => { -
-
+ + ) } From cece7e6495d3db4ea5f7133fd9109ce988abd5ba Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 8 Apr 2026 20:41:09 -0300 Subject: [PATCH 03/13] feat: only deep link to explorer when no explicit redirectTo is set When the callback URL has an explicit redirectTo (e.g. /marketplace), redirect there after login as usual, even with OPEN_EXPLORER_AFTER_LOGIN enabled. The deep link flow only activates when no destination was specified (redirectTo defaults to home). --- .../Pages/CallbackPage/CallbackPage.spec.tsx | 24 ++++++++++++++++++- .../Pages/CallbackPage/CallbackPage.tsx | 3 ++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx index d6741d3c..d306eb4f 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx @@ -13,8 +13,9 @@ jest.mock('../../../hooks/navigation', () => ({ })) const mockRedirect = jest.fn() +let mockRedirectUrl = 'https://decentraland.org/' jest.mock('../../../hooks/redirection', () => ({ - useAfterLoginRedirection: () => ({ url: 'https://decentraland.org/', redirect: mockRedirect }) + useAfterLoginRedirection: () => ({ url: mockRedirectUrl, redirect: mockRedirect }) })) const mockEnsureProfile = jest.fn() @@ -194,6 +195,7 @@ const renderWithProviders = (flags: Record = {}) => { describe('CallbackPage', () => { beforeEach(() => { + mockRedirectUrl = 'https://decentraland.org/' mockGetRedirectResult.mockResolvedValue({ oauth: { userInfo: {} } }) mockConnect.mockResolvedValue({ account: '0xTestAccount', @@ -274,6 +276,26 @@ describe('CallbackPage', () => { }) }) + describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is enabled and redirectTo has an explicit path', () => { + const mockIdentity = createMockIdentity() + + beforeEach(() => { + mockRedirectUrl = 'https://decentraland.org/marketplace' + ;(localStorageGetIdentity as jest.Mock).mockReturnValue(mockIdentity) + mockPostIdentity.mockResolvedValue({ identityId: 'test-identity-id' }) + }) + + it('should redirect instead of opening the deep link', async () => { + renderWithProviders({ 'dapps-open-explorer-after-login': true }) + + await waitFor(() => { + expect(mockRedirect).toHaveBeenCalled() + }) + + expect(mockPostIdentity).not.toHaveBeenCalled() + }) + }) + describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is enabled but identity is not in localStorage', () => { beforeEach(() => { ;(localStorageGetIdentity as jest.Mock).mockReturnValue(null) diff --git a/src/components/Pages/CallbackPage/CallbackPage.tsx b/src/components/Pages/CallbackPage/CallbackPage.tsx index 19d0cad9..9b41f3e2 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.tsx @@ -104,7 +104,8 @@ const DesktopCallbackPage = () => { type: ConnectionType.WEB2 }) - if (flags[FeatureFlagsKeys.OPEN_EXPLORER_AFTER_LOGIN]) { + const hasExplicitRedirect = redirectTo !== locations.home() && new URL(redirectTo, window.location.origin).pathname !== '/' + if (flags[FeatureFlagsKeys.OPEN_EXPLORER_AFTER_LOGIN] && !hasExplicitRedirect) { const freshIdentity = localStorageGetIdentity(ethAddress) if (freshIdentity) { const httpClient = createAuthServerHttpClient() From 5165adf14730009d5a465a394f302545d40ddd97 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Thu, 9 Apr 2026 08:50:19 -0300 Subject: [PATCH 04/13] chore: add social_auto_login translations for es, fr, it, zh --- src/modules/translations/es.json | 3 +++ src/modules/translations/fr.json | 3 +++ src/modules/translations/it.json | 3 +++ src/modules/translations/zh.json | 3 +++ 4 files changed, 12 insertions(+) diff --git a/src/modules/translations/es.json b/src/modules/translations/es.json index e2fb6ddb..ed213bc9 100644 --- a/src/modules/translations/es.json +++ b/src/modules/translations/es.json @@ -236,6 +236,9 @@ "description": "El sitio al que fuiste redirigido es inválido.", "go_back": "Regresar a la página de inicio de sesión" }, + "social_auto_login": { + "redirecting_to": "Redirigiendo a {provider}..." + }, "mobile_auth": { "sign_in_successful": "Inicio de sesión exitoso", "redirect_countdown": "Serás redirigido a\n{explorerText} en {countdown}...", diff --git a/src/modules/translations/fr.json b/src/modules/translations/fr.json index c331c140..5f6e219f 100644 --- a/src/modules/translations/fr.json +++ b/src/modules/translations/fr.json @@ -236,6 +236,9 @@ "description": "Le site vers lequel vous avez été redirigé est invalide.", "go_back": "Retourner à la page de connexion" }, + "social_auto_login": { + "redirecting_to": "Redirection vers {provider}..." + }, "mobile_auth": { "sign_in_successful": "Connexion réussie", "redirect_countdown": "Vous serez redirigé vers\n{explorerText} dans {countdown}...", diff --git a/src/modules/translations/it.json b/src/modules/translations/it.json index 74f20b94..ba4cd53a 100644 --- a/src/modules/translations/it.json +++ b/src/modules/translations/it.json @@ -236,6 +236,9 @@ "description": "Il sito a cui sei stato reindirizzato non è valido.", "go_back": "Torna alla pagina di accesso" }, + "social_auto_login": { + "redirecting_to": "Reindirizzamento a {provider}..." + }, "mobile_auth": { "sign_in_successful": "Accesso riuscito", "redirect_countdown": "Sarai reindirizzato a\n{explorerText} tra {countdown}...", diff --git a/src/modules/translations/zh.json b/src/modules/translations/zh.json index 8646695a..79e02b8f 100644 --- a/src/modules/translations/zh.json +++ b/src/modules/translations/zh.json @@ -236,6 +236,9 @@ "description": "您被重定向到的网站无效。", "go_back": "返回登录页面" }, + "social_auto_login": { + "redirecting_to": "正在重定向到 {provider}..." + }, "mobile_auth": { "sign_in_successful": "登录成功", "redirect_countdown": "您将被重定向到\n{explorerText},倒计时 {countdown}...", From d165a7c93f196dcafa6502dea1499022d7cdb40c Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Thu, 9 Apr 2026 09:17:34 -0300 Subject: [PATCH 05/13] fix: prevent infinite reload loop and duplicate OAuth redirect calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SocialAutoLoginRedirect: on error, navigate to /auth/login (without loginMethod) instead of window.location.reload(), which would loop because the URL still contains the loginMethod param - LoginPage: move socialAutoLoginType computation before useAutoLogin and pass isReady: false when social auto-login is active, preventing useAutoLogin from firing a duplicate connectToSocialProvider call - CallbackPage: simplify hasExplicitRedirect check — the redirectTo comparison with locations.home() was always true since redirectTo is a full URL while home() returns "/" --- .../Pages/CallbackPage/CallbackPage.tsx | 2 +- src/components/Pages/LoginPage/LoginPage.tsx | 22 ++++++++++--------- .../SocialAutoLoginRedirect.spec.tsx | 16 +++++++++----- .../LoginPage/SocialAutoLoginRedirect.tsx | 6 +++-- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/components/Pages/CallbackPage/CallbackPage.tsx b/src/components/Pages/CallbackPage/CallbackPage.tsx index 9b41f3e2..42a19d58 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.tsx @@ -104,7 +104,7 @@ const DesktopCallbackPage = () => { type: ConnectionType.WEB2 }) - const hasExplicitRedirect = redirectTo !== locations.home() && new URL(redirectTo, window.location.origin).pathname !== '/' + const hasExplicitRedirect = new URL(redirectTo, window.location.origin).pathname !== '/' if (flags[FeatureFlagsKeys.OPEN_EXPLORER_AFTER_LOGIN] && !hasExplicitRedirect) { const freshIdentity = localStorageGetIdentity(ethAddress) if (freshIdentity) { diff --git a/src/components/Pages/LoginPage/LoginPage.tsx b/src/components/Pages/LoginPage/LoginPage.tsx index 50129d62..ce0f86eb 100644 --- a/src/components/Pages/LoginPage/LoginPage.tsx +++ b/src/components/Pages/LoginPage/LoginPage.tsx @@ -374,9 +374,19 @@ export const LoginPage = () => { setShowEmailLoginModal(true) }, []) - // Use the auto-login hook to handle loginMethod URL parameter + // When loginMethod is a social provider, skip the full login UI and redirect immediately to OAuth. + // The full page (backgrounds, connection options, modals) is unnecessary since we just redirect away. + const socialAutoLoginType = useMemo(() => { + const param = new URLSearchParams(window.location.search).get('loginMethod')?.toLowerCase() + if (!param || !VALID_LOGIN_METHODS.includes(param as LoginMethod)) return null + const connectionOption = mapLoginMethodToConnectionOption(param as LoginMethod) + return isSocialLogin(connectionOption) ? connectionOption : null + }, []) + + // Use the auto-login hook to handle loginMethod URL parameter for non-social methods. + // Disabled when socialAutoLoginType is set to avoid a duplicate connectToSocialProvider call. useAutoLogin({ - isReady: flagInitialized, + isReady: flagInitialized && !socialAutoLoginType, onConnect: handleOnConnect }) @@ -440,14 +450,6 @@ export const LoginPage = () => { ) } - // When loginMethod is a social provider, skip the full login UI and redirect immediately to OAuth. - // The full page (backgrounds, connection options, modals) is unnecessary since we just redirect away. - const socialAutoLoginType = useMemo(() => { - const param = new URLSearchParams(window.location.search).get('loginMethod')?.toLowerCase() - if (!param || !VALID_LOGIN_METHODS.includes(param as LoginMethod)) return null - const connectionOption = mapLoginMethodToConnectionOption(param as LoginMethod) - return isSocialLogin(connectionOption) ? connectionOption : null - }, []) if (socialAutoLoginType) { return } diff --git a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx index 117e4770..1054c347 100644 --- a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx +++ b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx @@ -12,6 +12,12 @@ jest.mock('../../../shared/utils/errorHandler', () => ({ handleError: jest.fn() })) +jest.mock('../../../shared/locations', () => ({ + locations: { + login: () => '/auth/login' + } +})) + const mockTrackLoginClick = jest.fn() jest.mock('../../../hooks/useAnalytics', () => ({ useAnalytics: () => ({ @@ -124,23 +130,23 @@ describe('SocialAutoLoginRedirect', () => { }) describe('when connectToSocialProvider throws an error', () => { - let reloadMock: jest.Mock + const originalHref = window.location.href beforeEach(() => { mockConnectToSocialProvider.mockRejectedValue(new Error('Magic SDK failed')) - reloadMock = jest.fn() Object.defineProperty(window, 'location', { - value: { ...window.location, reload: reloadMock }, + value: { ...window.location, href: originalHref }, writable: true, configurable: true }) }) - it('should reload the page to show the full login UI', async () => { + it('should navigate to the login page without loginMethod', async () => { render() await waitFor(() => { - expect(reloadMock).toHaveBeenCalled() + expect(window.location.href).toContain('/login') + expect(window.location.href).not.toContain('loginMethod') }) }) }) diff --git a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx index 32b30f76..fd1cd601 100644 --- a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx +++ b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx @@ -4,6 +4,7 @@ import { CircularProgress } from 'decentraland-ui2' import { useAfterLoginRedirection } from '../../../hooks/redirection' import { useAnalytics } from '../../../hooks/useAnalytics' import { ConnectionType } from '../../../modules/analytics/types' +import { locations } from '../../../shared/locations' import { handleError } from '../../../shared/utils/errorHandler' import { AnimatedBackground } from '../../AnimatedBackground' import { ConnectionOptionType, connectionOptionTitles } from '../../Connection/Connection.types' @@ -44,10 +45,11 @@ export const SocialAutoLoginRedirect = ({ connectionType }: Props) => { startRedirect() }, [startRedirect]) - // On failure, reload to show the full login page so the user can retry or pick another method + // On failure, navigate to the login page without loginMethod to show the full UI. + // A simple reload would loop since the URL still contains loginMethod. useEffect(() => { if (failed) { - window.location.reload() + window.location.href = locations.login() } }, [failed]) From 84198e3744a98aefb8649aa6ab4138345f798aa7 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Thu, 9 Apr 2026 09:19:54 -0300 Subject: [PATCH 06/13] test: remove unnecessary mocks from CallbackPage tests Use real modules for shared/errors (pure type guards), ConnectionLayout.type (enum), @dcl/schemas (ProviderType enum), and shared/locations (pure functions) instead of mocking them. --- .../Pages/CallbackPage/CallbackPage.spec.tsx | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx index d306eb4f..f335a4d6 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx @@ -90,18 +90,6 @@ jest.mock('../../../shared/utils/errorHandler', () => ({ handleError: jest.fn() })) -jest.mock('../../../shared/errors', () => ({ - isMagicExtensionError: jest.fn().mockReturnValue(false), - isMagicRpcError: jest.fn().mockReturnValue(false) -})) - -jest.mock('../../../shared/locations', () => ({ - extractReferrerFromSearchParameters: jest.fn().mockReturnValue(null), - locations: { - login: jest.fn().mockReturnValue('/auth/login'), - home: jest.fn().mockReturnValue('/') - } -})) jest.mock('@dcl/single-sign-on-client', () => ({ localStorageGetIdentity: jest.fn() @@ -115,12 +103,6 @@ jest.mock('../../ConnectionModal/ConnectionLayout', () => ({ ConnectionLayout: ({ state }: { state: string }) =>
})) -jest.mock('../../ConnectionModal/ConnectionLayout.type', () => ({ - ConnectionLayoutState: { - VALIDATING_SIGN_IN: 'validating_sign_in', - ERROR_GENERIC: 'error_generic' - } -})) jest.mock('./CallbackPage.styled', () => { const Div = ({ children, ...props }: React.HTMLAttributes & { children?: React.ReactNode }) => ( @@ -157,12 +139,6 @@ jest.mock('decentraland-ui2', () => ({ CircularProgress: () => null })) -jest.mock('@dcl/schemas', () => ({ - ProviderType: { - MAGIC: 'magic', - MAGIC_TEST: 'magic_test' - } -})) // --- Helpers --- @@ -333,7 +309,7 @@ describe('CallbackPage', () => { renderWithProviders({}) await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/auth/login', { replace: true }) + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/login'), { replace: true }) }) }) }) From 93a2353267393257baa397a19fc72837605bb34c Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Thu, 9 Apr 2026 09:24:47 -0300 Subject: [PATCH 07/13] test: make varying inputs explicit in render helpers - CallbackPage: renderWithProviders now accepts { flags, redirectUrl } so each test call site shows the full context instead of relying on hidden beforeEach mutations of module-level variables - SocialAutoLoginRedirect: extract renderComponent helper, remove unnecessary shared/locations mock (pure function) --- .../Pages/CallbackPage/CallbackPage.spec.tsx | 35 ++++++++++--------- .../SocialAutoLoginRedirect.spec.tsx | 28 ++++++++------- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx index f335a4d6..f831e511 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx @@ -90,7 +90,6 @@ jest.mock('../../../shared/utils/errorHandler', () => ({ handleError: jest.fn() })) - jest.mock('@dcl/single-sign-on-client', () => ({ localStorageGetIdentity: jest.fn() })) @@ -103,7 +102,6 @@ jest.mock('../../ConnectionModal/ConnectionLayout', () => ({ ConnectionLayout: ({ state }: { state: string }) =>
})) - jest.mock('./CallbackPage.styled', () => { const Div = ({ children, ...props }: React.HTMLAttributes & { children?: React.ReactNode }) => (
{children}
@@ -139,10 +137,8 @@ jest.mock('decentraland-ui2', () => ({ CircularProgress: () => null })) - // --- Helpers --- -// Access the mocked FeatureFlagsContext to wrap renders // eslint-disable-next-line @typescript-eslint/no-require-imports const { FeatureFlagsContext } = require('../../FeatureFlagsProvider') @@ -157,7 +153,13 @@ const createMockIdentity = (): AuthIdentity => authChain: [] }) as unknown as AuthIdentity -const renderWithProviders = (flags: Record = {}) => { +interface RenderOptions { + flags?: Record + redirectUrl?: string +} + +const renderWithProviders = ({ flags = {}, redirectUrl = 'https://decentraland.org/' }: RenderOptions = {}) => { + mockRedirectUrl = redirectUrl return render( @@ -171,7 +173,6 @@ const renderWithProviders = (flags: Record = {}) => { describe('CallbackPage', () => { beforeEach(() => { - mockRedirectUrl = 'https://decentraland.org/' mockGetRedirectResult.mockResolvedValue({ oauth: { userInfo: {} } }) mockConnect.mockResolvedValue({ account: '0xTestAccount', @@ -196,7 +197,7 @@ describe('CallbackPage', () => { }) it('should post the identity to the auth server', async () => { - renderWithProviders({ 'dapps-open-explorer-after-login': true }) + renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) await waitFor(() => { expect(mockPostIdentity).toHaveBeenCalledWith(mockIdentity, { isMobile: false }) @@ -204,7 +205,7 @@ describe('CallbackPage', () => { }) it('should render DesktopAuthSuccess with the identity id', async () => { - const { getByTestId } = renderWithProviders({ 'dapps-open-explorer-after-login': true }) + const { getByTestId } = renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) await waitFor(() => { const successEl = getByTestId('desktop-auth-success') @@ -214,7 +215,7 @@ describe('CallbackPage', () => { }) it('should not call redirect', async () => { - renderWithProviders({ 'dapps-open-explorer-after-login': true }) + renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) await waitFor(() => { expect(mockPostIdentity).toHaveBeenCalled() @@ -224,7 +225,7 @@ describe('CallbackPage', () => { }) it('should mark the user as returning', async () => { - renderWithProviders({ 'dapps-open-explorer-after-login': true }) + renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) await waitFor(() => { expect(mockMarkReturningUser).toHaveBeenCalledWith('0xTestAccount') @@ -234,7 +235,7 @@ describe('CallbackPage', () => { describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is disabled', () => { it('should not post the identity to the auth server', async () => { - renderWithProviders({}) + renderWithProviders() await waitFor(() => { expect(mockRedirect).toHaveBeenCalled() @@ -244,7 +245,7 @@ describe('CallbackPage', () => { }) it('should call redirect', async () => { - renderWithProviders({}) + renderWithProviders() await waitFor(() => { expect(mockRedirect).toHaveBeenCalled() @@ -256,13 +257,15 @@ describe('CallbackPage', () => { const mockIdentity = createMockIdentity() beforeEach(() => { - mockRedirectUrl = 'https://decentraland.org/marketplace' ;(localStorageGetIdentity as jest.Mock).mockReturnValue(mockIdentity) mockPostIdentity.mockResolvedValue({ identityId: 'test-identity-id' }) }) it('should redirect instead of opening the deep link', async () => { - renderWithProviders({ 'dapps-open-explorer-after-login': true }) + renderWithProviders({ + flags: { 'dapps-open-explorer-after-login': true }, + redirectUrl: 'https://decentraland.org/marketplace' + }) await waitFor(() => { expect(mockRedirect).toHaveBeenCalled() @@ -278,7 +281,7 @@ describe('CallbackPage', () => { }) it('should fall through to the normal redirect flow', async () => { - renderWithProviders({ 'dapps-open-explorer-after-login': true }) + renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) await waitFor(() => { expect(mockRedirect).toHaveBeenCalled() @@ -306,7 +309,7 @@ describe('CallbackPage', () => { }) it('should navigate back to the login page', async () => { - renderWithProviders({}) + renderWithProviders() await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/login'), { replace: true }) diff --git a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx index 1054c347..c16b8ac4 100644 --- a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx +++ b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx @@ -12,12 +12,6 @@ jest.mock('../../../shared/utils/errorHandler', () => ({ handleError: jest.fn() })) -jest.mock('../../../shared/locations', () => ({ - locations: { - login: () => '/auth/login' - } -})) - const mockTrackLoginClick = jest.fn() jest.mock('../../../hooks/useAnalytics', () => ({ useAnalytics: () => ({ @@ -80,6 +74,14 @@ jest.mock('decentraland-ui2', () => ({ CircularProgress: () =>
})) +// --- Helpers --- + +const renderComponent = (connectionType: ConnectionOptionType) => { + return render() +} + +// --- Tests --- + describe('SocialAutoLoginRedirect', () => { afterEach(() => { jest.clearAllMocks() @@ -87,22 +89,22 @@ describe('SocialAutoLoginRedirect', () => { describe('when rendered with a social connection type', () => { it('should render the animated background', () => { - const { getByTestId } = render() + const { getByTestId } = renderComponent(ConnectionOptionType.GOOGLE) expect(getByTestId('animated-background')).toBeTruthy() }) it('should show a redirecting message with the provider name', () => { - const { container } = render() + const { container } = renderComponent(ConnectionOptionType.GOOGLE) expect(container.textContent).toContain('Redirecting to Google...') }) it('should render a loading spinner', () => { - const { getByTestId } = render() + const { getByTestId } = renderComponent(ConnectionOptionType.GOOGLE) expect(getByTestId('loading-spinner')).toBeTruthy() }) it('should track the login click', async () => { - render() + renderComponent(ConnectionOptionType.GOOGLE) await waitFor(() => { expect(mockTrackLoginClick).toHaveBeenCalledWith({ @@ -113,7 +115,7 @@ describe('SocialAutoLoginRedirect', () => { }) it('should call connectToSocialProvider with the correct arguments', async () => { - render() + renderComponent(ConnectionOptionType.GOOGLE) await waitFor(() => { expect(mockConnectToSocialProvider).toHaveBeenCalledWith(ConnectionOptionType.GOOGLE, false, 'https://decentraland.org/play') @@ -121,7 +123,7 @@ describe('SocialAutoLoginRedirect', () => { }) it('should not call connectToSocialProvider twice', async () => { - render() + renderComponent(ConnectionOptionType.DISCORD) await waitFor(() => { expect(mockConnectToSocialProvider).toHaveBeenCalledTimes(1) @@ -142,7 +144,7 @@ describe('SocialAutoLoginRedirect', () => { }) it('should navigate to the login page without loginMethod', async () => { - render() + renderComponent(ConnectionOptionType.GOOGLE) await waitFor(() => { expect(window.location.href).toContain('/login') From cafb9bc64c51433da409e2e57309568b02d4bedc Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Thu, 9 Apr 2026 09:34:52 -0300 Subject: [PATCH 08/13] refactor: extract OpenExplorerPage as a standalone route Move the deep link + countdown logic from DesktopAuthSuccess (inline in CallbackPage) into a new /auth/open-explorer page. The page reads the identityId from the URL search params, so any login flow (social, wallet, email) can navigate there after posting the identity. CallbackPage now navigates to the new page with history replace instead of rendering the success UI inline. --- .../Pages/CallbackPage/CallbackPage.spec.tsx | 15 ++-- .../Pages/CallbackPage/CallbackPage.tsx | 8 +- .../OpenExplorerPage.spec.tsx} | 87 +++++++++++-------- .../OpenExplorerPage.styled.ts | 19 ++++ .../OpenExplorerPage.tsx} | 45 +++++++--- .../Pages/OpenExplorerPage/index.ts | 1 + src/main.tsx | 2 + src/shared/locations.ts | 1 + 8 files changed, 114 insertions(+), 64 deletions(-) rename src/components/Pages/{CallbackPage/DesktopAuthSuccess.spec.tsx => OpenExplorerPage/OpenExplorerPage.spec.tsx} (60%) create mode 100644 src/components/Pages/OpenExplorerPage/OpenExplorerPage.styled.ts rename src/components/Pages/{CallbackPage/DesktopAuthSuccess.tsx => OpenExplorerPage/OpenExplorerPage.tsx} (66%) create mode 100644 src/components/Pages/OpenExplorerPage/index.ts diff --git a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx index f831e511..12dd3db2 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx @@ -109,10 +109,6 @@ jest.mock('./CallbackPage.styled', () => { return { Container: Div, Wrapper: Div } }) -jest.mock('./DesktopAuthSuccess', () => ({ - DesktopAuthSuccess: ({ identityId }: { identityId: string }) =>
-})) - jest.mock('../MobileCallbackPage/MobileCallbackPage', () => ({ MobileCallbackPage: () =>
})) @@ -204,13 +200,14 @@ describe('CallbackPage', () => { }) }) - it('should render DesktopAuthSuccess with the identity id', async () => { - const { getByTestId } = renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) + it('should navigate to the open explorer page with the identity id', async () => { + renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) await waitFor(() => { - const successEl = getByTestId('desktop-auth-success') - expect(successEl).toBeTruthy() - expect(successEl.getAttribute('data-identity-id')).toBe('test-identity-id') + expect(mockNavigate).toHaveBeenCalledWith( + expect.stringContaining('/open-explorer?identityId=test-identity-id'), + { replace: true } + ) }) }) diff --git a/src/components/Pages/CallbackPage/CallbackPage.tsx b/src/components/Pages/CallbackPage/CallbackPage.tsx index 42a19d58..39fa1daa 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.tsx @@ -24,7 +24,6 @@ import { ConnectionLayout } from '../../ConnectionModal/ConnectionLayout' import { ConnectionLayoutState } from '../../ConnectionModal/ConnectionLayout.type' import { FeatureFlagsContext, FeatureFlagsKeys } from '../../FeatureFlagsProvider' import { MobileCallbackPage } from '../MobileCallbackPage/MobileCallbackPage' -import { DesktopAuthSuccess } from './DesktopAuthSuccess' import { Container, Wrapper } from './CallbackPage.styled' const CallbackPage = () => { @@ -43,7 +42,6 @@ const DesktopCallbackPage = () => { const [logInStarted, setLogInStarted] = useState(false) const [layoutState, setLayoutState] = useState(ConnectionLayoutState.VALIDATING_SIGN_IN) const [errorDetail, setErrorDetail] = useState(null) - const [identityId, setIdentityId] = useState(null) const { initialized, flags } = useContext(FeatureFlagsContext) const [targetConfig] = useTargetConfig() const { ensureProfile } = useEnsureProfile() @@ -111,7 +109,7 @@ const DesktopCallbackPage = () => { const httpClient = createAuthServerHttpClient() const response = await httpClient.postIdentity(freshIdentity, { isMobile: false }) markReturningUser(connectionData.account ?? '') - setIdentityId(response.identityId) + navigate(locations.openExplorer(response.identityId), { replace: true }) return } console.warn('OPEN_EXPLORER_AFTER_LOGIN enabled but identity not found in localStorage for', ethAddress) @@ -216,10 +214,6 @@ const DesktopCallbackPage = () => { } }, [logInAndRedirect, initialized, logInStarted]) - if (identityId) { - return - } - return ( diff --git a/src/components/Pages/CallbackPage/DesktopAuthSuccess.spec.tsx b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx similarity index 60% rename from src/components/Pages/CallbackPage/DesktopAuthSuccess.spec.tsx rename to src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx index 21b51af0..a59e592d 100644 --- a/src/components/Pages/CallbackPage/DesktopAuthSuccess.spec.tsx +++ b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx @@ -1,6 +1,7 @@ +/* eslint-disable @typescript-eslint/naming-convention -- mock shapes must match exported names */ import { act, render, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { DesktopAuthSuccess } from './DesktopAuthSuccess' +import { OpenExplorerPage } from './OpenExplorerPage' const mockLaunchDeepLink = jest.fn() jest.mock('../RequestPage/utils', () => ({ @@ -16,6 +17,20 @@ jest.mock('../../../modules/config', () => ({ } })) +const mockNavigate = jest.fn() +jest.mock('../../../hooks/navigation', () => ({ + useNavigateWithSearchParams: () => mockNavigate +})) + +jest.mock('../../../hooks/targetConfig', () => ({ + useTargetConfig: () => [ + { + explorerText: 'Decentraland app' + }, + 'default' + ] +})) + jest.mock('@dcl/hooks', () => ({ useTranslation: () => ({ t: (key: string, params?: Record) => { @@ -33,7 +48,7 @@ jest.mock('../../AnimatedBackground', () => ({ AnimatedBackground: () =>
})) -jest.mock('./CallbackPage.styled', () => { +jest.mock('./OpenExplorerPage.styled', () => { const Div = ({ children, ...props }: React.HTMLAttributes & { children?: React.ReactNode }) => (
{children}
) @@ -54,11 +69,7 @@ jest.mock('../../ConnectionModal/ConnectionLayout.styled', () => { }) jest.mock('decentraland-ui2', () => ({ - Button: ({ - children, - onClick, - ...props - }: React.ButtonHTMLAttributes & { variant?: string; children?: React.ReactNode }) => ( + Button: ({ children, onClick, ...props }: React.ButtonHTMLAttributes & { variant?: string; children?: React.ReactNode }) => ( @@ -66,12 +77,18 @@ jest.mock('decentraland-ui2', () => ({ CircularProgress: () =>
})) -describe('DesktopAuthSuccess', () => { - const mockOnTryAgain = jest.fn() +// Mock useSearchParams to control the identityId param +let mockSearchParams = new URLSearchParams('identityId=test-id-123') +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useSearchParams: () => [mockSearchParams] +})) +describe('OpenExplorerPage', () => { beforeEach(() => { jest.useFakeTimers() mockLaunchDeepLink.mockResolvedValue(true) + mockSearchParams = new URLSearchParams('identityId=test-id-123') }) afterEach(() => { @@ -79,25 +96,19 @@ describe('DesktopAuthSuccess', () => { jest.clearAllMocks() }) - describe('when rendered with an identity id', () => { + describe('when rendered with an identityId search param', () => { it('should show a countdown message', () => { - const { container } = render( - - ) - + const { container } = render() expect(container.textContent).toContain('Opening Decentraland app in 3...') }) it('should show a button to open the explorer', () => { - const { getByTestId } = render( - - ) - - expect(getByTestId('desktop-auth-open-explorer-button')).toBeTruthy() + const { getByTestId } = render() + expect(getByTestId('open-explorer-button')).toBeTruthy() }) it('should attempt deep link after countdown', async () => { - render() + render() act(() => { jest.advanceTimersByTime(3000) @@ -115,47 +126,53 @@ describe('DesktopAuthSuccess', () => { }) it('should show a try again button', async () => { - const { getByTestId } = render( - - ) + const { getByTestId } = render() act(() => { jest.advanceTimersByTime(3000) }) await waitFor(() => { - expect(getByTestId('desktop-auth-try-again-button')).toBeTruthy() + expect(getByTestId('open-explorer-try-again-button')).toBeTruthy() }) }) - it('should call onTryAgain when the try again button is clicked', async () => { + it('should navigate to login when try again is clicked', async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) - const { getByTestId } = render( - - ) + const { getByTestId } = render() act(() => { jest.advanceTimersByTime(3000) }) await waitFor(() => { - expect(getByTestId('desktop-auth-try-again-button')).toBeTruthy() + expect(getByTestId('open-explorer-try-again-button')).toBeTruthy() }) - await user.click(getByTestId('desktop-auth-try-again-button')) + await user.click(getByTestId('open-explorer-try-again-button')) + + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/login'), { replace: true }) + }) + }) + + describe('when no identityId is provided', () => { + beforeEach(() => { + mockSearchParams = new URLSearchParams('') + }) + + it('should navigate to the login page', () => { + render() - expect(mockOnTryAgain).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/login'), { replace: true }) }) }) describe('when the open explorer button is clicked', () => { it('should attempt to launch the deep link', async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) - const { getByTestId } = render( - - ) + const { getByTestId } = render() - await user.click(getByTestId('desktop-auth-open-explorer-button')) + await user.click(getByTestId('open-explorer-button')) expect(mockLaunchDeepLink).toHaveBeenCalledWith('decentraland://?dclenv=org&signin=test-id-123') }) diff --git a/src/components/Pages/OpenExplorerPage/OpenExplorerPage.styled.ts b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.styled.ts new file mode 100644 index 00000000..86aa04e6 --- /dev/null +++ b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.styled.ts @@ -0,0 +1,19 @@ +import { Box, styled } from 'decentraland-ui2' + +const Container = styled(Box)({ + display: 'flex', + height: '100vh', + width: '100vw', + position: 'relative' +}) + +const Wrapper = styled(Box)({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + width: '100%' +}) + +export { Container, Wrapper } diff --git a/src/components/Pages/CallbackPage/DesktopAuthSuccess.tsx b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx similarity index 66% rename from src/components/Pages/CallbackPage/DesktopAuthSuccess.tsx rename to src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx index 83209261..30a5bfab 100644 --- a/src/components/Pages/CallbackPage/DesktopAuthSuccess.tsx +++ b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx @@ -1,7 +1,11 @@ import { useCallback, useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import { useTranslation } from '@dcl/hooks' import { Button, CircularProgress } from 'decentraland-ui2' +import { useNavigateWithSearchParams } from '../../../hooks/navigation' +import { useTargetConfig } from '../../../hooks/targetConfig' import { config } from '../../../modules/config' +import { locations } from '../../../shared/locations' import { AnimatedBackground } from '../../AnimatedBackground' import { ConnectionContainer, @@ -11,7 +15,7 @@ import { ProgressContainer } from '../../ConnectionModal/ConnectionLayout.styled' import { launchDeepLink } from '../RequestPage/utils' -import { Container, Wrapper } from './CallbackPage.styled' +import { Container, Wrapper } from './OpenExplorerPage.styled' const COUNTDOWN_SECONDS = 3 @@ -21,33 +25,46 @@ const ENVIRONMENT_TO_DCLENV: Record = { production: 'org' } -type Props = { - identityId: string - explorerText: string - onTryAgain: () => void -} - -export const DesktopAuthSuccess = ({ identityId, explorerText, onTryAgain }: Props) => { +export const OpenExplorerPage = () => { const { t } = useTranslation() + const navigate = useNavigateWithSearchParams() + const [searchParams] = useSearchParams() + const [targetConfig] = useTargetConfig() const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS) const [deepLinkFailed, setDeepLinkFailed] = useState(false) + const identityId = searchParams.get('identityId') + const explorerText = targetConfig.explorerText + const environment = config.get('ENVIRONMENT').toLowerCase() const dclenv = ENVIRONMENT_TO_DCLENV[environment] if (!dclenv) { console.warn('Unknown ENVIRONMENT value for deep link:', environment, '— defaulting to org') } - const deepLinkUrl = `decentraland://?dclenv=${dclenv ?? 'org'}&signin=${identityId}` + const deepLinkUrl = identityId ? `decentraland://?dclenv=${dclenv ?? 'org'}&signin=${identityId}` : null const attemptDeepLink = useCallback(async () => { + if (!deepLinkUrl) return const wasLaunched = await launchDeepLink(deepLinkUrl) if (!wasLaunched) { setDeepLinkFailed(true) } }, [deepLinkUrl]) + const handleTryAgain = useCallback(() => { + navigate(locations.login(), { replace: true }) + }, [navigate]) + + // Redirect to login if no identityId was provided useEffect(() => { - if (deepLinkFailed) return + if (!identityId) { + navigate(locations.login(), { replace: true }) + } + }, [identityId, navigate]) + + // Countdown and auto-launch deep link + useEffect(() => { + if (deepLinkFailed || !identityId) return const interval = setInterval(() => { setCountdown(prev => { @@ -61,7 +78,9 @@ export const DesktopAuthSuccess = ({ identityId, explorerText, onTryAgain }: Pro }, 1000) return () => clearInterval(interval) - }, [deepLinkFailed, attemptDeepLink]) + }, [deepLinkFailed, attemptDeepLink, identityId]) + + if (!identityId) return null return ( @@ -73,7 +92,7 @@ export const DesktopAuthSuccess = ({ identityId, explorerText, onTryAgain }: Pro <> {t('mobile_auth.could_not_open', { explorerText })} - @@ -88,7 +107,7 @@ export const DesktopAuthSuccess = ({ identityId, explorerText, onTryAgain }: Pro - diff --git a/src/components/Pages/OpenExplorerPage/index.ts b/src/components/Pages/OpenExplorerPage/index.ts new file mode 100644 index 00000000..15743ec4 --- /dev/null +++ b/src/components/Pages/OpenExplorerPage/index.ts @@ -0,0 +1 @@ +export { OpenExplorerPage } from './OpenExplorerPage' diff --git a/src/main.tsx b/src/main.tsx index 6f8a7437..b6e02ad8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -18,6 +18,7 @@ import { InvalidRedirectionPage } from './components/Pages/InvalidRedirectionPag import { LoginPage } from './components/Pages/LoginPage' import { MobileAuthPage } from './components/Pages/MobileAuthPage' import { MobileCallbackPage } from './components/Pages/MobileCallbackPage' +import { OpenExplorerPage } from './components/Pages/OpenExplorerPage' import { FeatureFlagsProvider } from './components/FeatureFlagsProvider' import { ConnectionProvider } from './shared/connection' import { config } from './modules/config' @@ -84,6 +85,7 @@ const SiteRoutes = () => { } /> ) : null} + diff --git a/src/shared/locations.ts b/src/shared/locations.ts index e311e4cd..b052b67b 100644 --- a/src/shared/locations.ts +++ b/src/shared/locations.ts @@ -73,6 +73,7 @@ const locations = { `/avatar-setup${redirectTo ? `?redirectTo=${encodeURIComponent(redirectTo)}` : ''}${ referrer ? `${redirectTo ? '&' : '?'}referrer=${encodeURIComponent(referrer)}` : '' }`, + openExplorer: (identityId: string) => `/open-explorer?identityId=${encodeURIComponent(identityId)}`, mobile: (provider?: string) => `/mobile${provider ? `?provider=${encodeURIComponent(provider)}` : ''}`, mobileCallback: () => '/mobile/callback' } From eeca85e0365a203be86e45e2d60753ed76172515 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Thu, 9 Apr 2026 09:41:22 -0300 Subject: [PATCH 09/13] refactor: make OpenExplorerPage self-contained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenExplorerPage now reads the account from ConnectionProvider, gets the identity from localStorage, and posts it to the auth server itself. No more identityId in the URL — the page encapsulates the full flow from identity posting to deep link launch. CallbackPage just navigates to /open-explorer after login when the flag is enabled, without needing to know about identities or the auth server client. --- .../Pages/CallbackPage/CallbackPage.spec.tsx | 67 +-------- .../Pages/CallbackPage/CallbackPage.tsx | 13 +- .../OpenExplorerPage.spec.tsx | 131 +++++++++++++----- .../OpenExplorerPage/OpenExplorerPage.tsx | 96 +++++++++++-- src/shared/locations.ts | 2 +- 5 files changed, 188 insertions(+), 121 deletions(-) diff --git a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx index 12dd3db2..1ebd12eb 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.spec.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx @@ -2,7 +2,6 @@ import { BrowserRouter } from 'react-router-dom' import { render, waitFor } from '@testing-library/react' import type { AuthIdentity } from '@dcl/crypto' -import { localStorageGetIdentity } from '@dcl/single-sign-on-client' import { CallbackPage } from './CallbackPage' // --- Mocks --- @@ -50,13 +49,6 @@ jest.mock('../../../shared/mobile', () => ({ isMobileSession: () => false })) -const mockPostIdentity = jest.fn() -jest.mock('../../../shared/auth', () => ({ - createAuthServerHttpClient: () => ({ - postIdentity: mockPostIdentity - }) -})) - const mockConnect = jest.fn() jest.mock('decentraland-connect', () => ({ connection: { @@ -90,10 +82,6 @@ jest.mock('../../../shared/utils/errorHandler', () => ({ handleError: jest.fn() })) -jest.mock('@dcl/single-sign-on-client', () => ({ - localStorageGetIdentity: jest.fn() -})) - jest.mock('../../AnimatedBackground', () => ({ AnimatedBackground: () =>
})) @@ -185,29 +173,11 @@ describe('CallbackPage', () => { }) describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is enabled', () => { - const mockIdentity = createMockIdentity() - - beforeEach(() => { - ;(localStorageGetIdentity as jest.Mock).mockReturnValue(mockIdentity) - mockPostIdentity.mockResolvedValue({ identityId: 'test-identity-id' }) - }) - - it('should post the identity to the auth server', async () => { - renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) - - await waitFor(() => { - expect(mockPostIdentity).toHaveBeenCalledWith(mockIdentity, { isMobile: false }) - }) - }) - - it('should navigate to the open explorer page with the identity id', async () => { + it('should navigate to the open explorer page', async () => { renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringContaining('/open-explorer?identityId=test-identity-id'), - { replace: true } - ) + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/open-explorer'), { replace: true }) }) }) @@ -215,7 +185,7 @@ describe('CallbackPage', () => { renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) await waitFor(() => { - expect(mockPostIdentity).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/open-explorer'), { replace: true }) }) expect(mockRedirect).not.toHaveBeenCalled() @@ -231,14 +201,14 @@ describe('CallbackPage', () => { }) describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is disabled', () => { - it('should not post the identity to the auth server', async () => { + it('should not navigate to the open explorer page', async () => { renderWithProviders() await waitFor(() => { expect(mockRedirect).toHaveBeenCalled() }) - expect(mockPostIdentity).not.toHaveBeenCalled() + expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining('/open-explorer'), expect.anything()) }) it('should call redirect', async () => { @@ -251,14 +221,7 @@ describe('CallbackPage', () => { }) describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is enabled and redirectTo has an explicit path', () => { - const mockIdentity = createMockIdentity() - - beforeEach(() => { - ;(localStorageGetIdentity as jest.Mock).mockReturnValue(mockIdentity) - mockPostIdentity.mockResolvedValue({ identityId: 'test-identity-id' }) - }) - - it('should redirect instead of opening the deep link', async () => { + it('should redirect instead of navigating to open explorer', async () => { renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true }, redirectUrl: 'https://decentraland.org/marketplace' @@ -268,23 +231,7 @@ describe('CallbackPage', () => { expect(mockRedirect).toHaveBeenCalled() }) - expect(mockPostIdentity).not.toHaveBeenCalled() - }) - }) - - describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is enabled but identity is not in localStorage', () => { - beforeEach(() => { - ;(localStorageGetIdentity as jest.Mock).mockReturnValue(null) - }) - - it('should fall through to the normal redirect flow', async () => { - renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) - - await waitFor(() => { - expect(mockRedirect).toHaveBeenCalled() - }) - - expect(mockPostIdentity).not.toHaveBeenCalled() + expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining('/open-explorer'), expect.anything()) }) }) diff --git a/src/components/Pages/CallbackPage/CallbackPage.tsx b/src/components/Pages/CallbackPage/CallbackPage.tsx index 39fa1daa..8cd59de3 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.tsx @@ -9,7 +9,6 @@ import { useTargetConfig } from '../../../hooks/targetConfig' import { useAnalytics } from '../../../hooks/useAnalytics' import { useEnsureProfile } from '../../../hooks/useEnsureProfile' import { ConnectionType } from '../../../modules/analytics/types' -import { createAuthServerHttpClient } from '../../../shared/auth' import { useCurrentConnectionData } from '../../../shared/connection' import { isMagicExtensionError, isMagicRpcError } from '../../../shared/errors' import { extractReferrerFromSearchParameters, locations } from '../../../shared/locations' @@ -104,15 +103,9 @@ const DesktopCallbackPage = () => { const hasExplicitRedirect = new URL(redirectTo, window.location.origin).pathname !== '/' if (flags[FeatureFlagsKeys.OPEN_EXPLORER_AFTER_LOGIN] && !hasExplicitRedirect) { - const freshIdentity = localStorageGetIdentity(ethAddress) - if (freshIdentity) { - const httpClient = createAuthServerHttpClient() - const response = await httpClient.postIdentity(freshIdentity, { isMobile: false }) - markReturningUser(connectionData.account ?? '') - navigate(locations.openExplorer(response.identityId), { replace: true }) - return - } - console.warn('OPEN_EXPLORER_AFTER_LOGIN enabled but identity not found in localStorage for', ethAddress) + markReturningUser(connectionData.account ?? '') + navigate(locations.openExplorer(), { replace: true }) + return } const account = connectionData.account ?? '' diff --git a/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx index a59e592d..be9356b3 100644 --- a/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx +++ b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention -- mock shapes must match exported names */ import { act, render, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import type { AuthIdentity } from '@dcl/crypto' +import { localStorageGetIdentity } from '@dcl/single-sign-on-client' import { OpenExplorerPage } from './OpenExplorerPage' const mockLaunchDeepLink = jest.fn() @@ -31,6 +33,30 @@ jest.mock('../../../hooks/targetConfig', () => ({ ] })) +const mockPostIdentity = jest.fn() +jest.mock('../../../shared/auth', () => ({ + createAuthServerHttpClient: () => ({ + postIdentity: mockPostIdentity + }) +})) + +let mockAccount: string | undefined = '0xTestAccount' +jest.mock('../../../shared/connection', () => ({ + useCurrentConnectionData: () => ({ + get account() { + return mockAccount + } + }) +})) + +jest.mock('@dcl/single-sign-on-client', () => ({ + localStorageGetIdentity: jest.fn() +})) + +jest.mock('../../../shared/utils/errorHandler', () => ({ + handleError: jest.fn() +})) + jest.mock('@dcl/hooks', () => ({ useTranslation: () => ({ t: (key: string, params?: Record) => { @@ -38,6 +64,7 @@ jest.mock('@dcl/hooks', () => ({ if (key === 'mobile_auth.redirecting') return `Redirecting to ${params?.explorerText}...` if (key === 'mobile_auth.could_not_open') return `Could not open ${params?.explorerText}` if (key === 'mobile_auth.return_to') return `Open ${params?.explorerText}` + if (key === 'connection_layout.validating_sign_in') return 'Verifying...' if (key === 'common.try_again') return 'Try again' return key } @@ -69,7 +96,11 @@ jest.mock('../../ConnectionModal/ConnectionLayout.styled', () => { }) jest.mock('decentraland-ui2', () => ({ - Button: ({ children, onClick, ...props }: React.ButtonHTMLAttributes & { variant?: string; children?: React.ReactNode }) => ( + Button: ({ + children, + onClick, + ...props + }: React.ButtonHTMLAttributes & { variant?: string; children?: React.ReactNode }) => ( @@ -77,18 +108,30 @@ jest.mock('decentraland-ui2', () => ({ CircularProgress: () =>
})) -// Mock useSearchParams to control the identityId param -let mockSearchParams = new URLSearchParams('identityId=test-id-123') -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useSearchParams: () => [mockSearchParams] -})) +// --- Helpers --- + +const createMockIdentity = (): AuthIdentity => + ({ + ephemeralIdentity: { + privateKey: '0x' + 'a'.repeat(64), + publicKey: '0x' + 'b'.repeat(130), + address: '0x' + 'c'.repeat(40) + }, + expiration: new Date(Date.now() + 60_000), + authChain: [] + }) as unknown as AuthIdentity + +// --- Tests --- describe('OpenExplorerPage', () => { + const mockIdentity = createMockIdentity() + beforeEach(() => { jest.useFakeTimers() + mockAccount = '0xTestAccount' mockLaunchDeepLink.mockResolvedValue(true) - mockSearchParams = new URLSearchParams('identityId=test-id-123') + ;(localStorageGetIdentity as jest.Mock).mockReturnValue(mockIdentity) + mockPostIdentity.mockResolvedValue({ identityId: 'test-id-123' }) }) afterEach(() => { @@ -96,19 +139,30 @@ describe('OpenExplorerPage', () => { jest.clearAllMocks() }) - describe('when rendered with an identityId search param', () => { - it('should show a countdown message', () => { - const { container } = render() - expect(container.textContent).toContain('Opening Decentraland app in 3...') + describe('when the account and identity are available', () => { + it('should post the identity to the auth server', async () => { + render() + + await waitFor(() => { + expect(mockPostIdentity).toHaveBeenCalledWith(mockIdentity, { isMobile: false }) + }) }) - it('should show a button to open the explorer', () => { - const { getByTestId } = render() - expect(getByTestId('open-explorer-button')).toBeTruthy() + it('should show a countdown after posting the identity', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.textContent).toContain('Opening Decentraland app in 3...') + }) }) it('should attempt deep link after countdown', async () => { - render() + const { container } = render() + + // Wait for postIdentity to resolve and countdown to appear + await waitFor(() => { + expect(container.textContent).toContain('Opening Decentraland app in') + }) act(() => { jest.advanceTimersByTime(3000) @@ -120,13 +174,17 @@ describe('OpenExplorerPage', () => { }) }) - describe('when deep link fails', () => { + describe('when the deep link fails', () => { beforeEach(() => { mockLaunchDeepLink.mockResolvedValue(false) }) it('should show a try again button', async () => { - const { getByTestId } = render() + const { container, getByTestId } = render() + + await waitFor(() => { + expect(container.textContent).toContain('Opening Decentraland app in') + }) act(() => { jest.advanceTimersByTime(3000) @@ -136,41 +194,44 @@ describe('OpenExplorerPage', () => { expect(getByTestId('open-explorer-try-again-button')).toBeTruthy() }) }) + }) - it('should navigate to login when try again is clicked', async () => { - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) - const { getByTestId } = render() + describe('when no account is available', () => { + beforeEach(() => { + mockAccount = undefined + }) - act(() => { - jest.advanceTimersByTime(3000) - }) + it('should navigate to the login page', async () => { + render() await waitFor(() => { - expect(getByTestId('open-explorer-try-again-button')).toBeTruthy() + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/login'), { replace: true }) }) - - await user.click(getByTestId('open-explorer-try-again-button')) - - expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/login'), { replace: true }) }) }) - describe('when no identityId is provided', () => { + describe('when postIdentity fails', () => { beforeEach(() => { - mockSearchParams = new URLSearchParams('') + mockPostIdentity.mockRejectedValue(new Error('Server error')) }) - it('should navigate to the login page', () => { - render() + it('should show an error state with try again', async () => { + const { getByTestId } = render() - expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/login'), { replace: true }) + await waitFor(() => { + expect(getByTestId('open-explorer-try-again-button')).toBeTruthy() + }) }) }) describe('when the open explorer button is clicked', () => { it('should attempt to launch the deep link', async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) - const { getByTestId } = render() + const { container, getByTestId } = render() + + await waitFor(() => { + expect(container.textContent).toContain('Opening Decentraland app in') + }) await user.click(getByTestId('open-explorer-button')) diff --git a/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx index 30a5bfab..355095e8 100644 --- a/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx +++ b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx @@ -1,11 +1,14 @@ -import { useCallback, useEffect, useState } from 'react' -import { useSearchParams } from 'react-router-dom' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from '@dcl/hooks' +import { localStorageGetIdentity } from '@dcl/single-sign-on-client' import { Button, CircularProgress } from 'decentraland-ui2' import { useNavigateWithSearchParams } from '../../../hooks/navigation' import { useTargetConfig } from '../../../hooks/targetConfig' import { config } from '../../../modules/config' +import { createAuthServerHttpClient } from '../../../shared/auth' +import { useCurrentConnectionData } from '../../../shared/connection' import { locations } from '../../../shared/locations' +import { handleError } from '../../../shared/utils/errorHandler' import { AnimatedBackground } from '../../AnimatedBackground' import { ConnectionContainer, @@ -28,12 +31,15 @@ const ENVIRONMENT_TO_DCLENV: Record = { export const OpenExplorerPage = () => { const { t } = useTranslation() const navigate = useNavigateWithSearchParams() - const [searchParams] = useSearchParams() const [targetConfig] = useTargetConfig() + const { account } = useCurrentConnectionData() + const hasStartedPosting = useRef(false) + + const [identityId, setIdentityId] = useState(null) const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS) const [deepLinkFailed, setDeepLinkFailed] = useState(false) + const [error, setError] = useState(false) - const identityId = searchParams.get('identityId') const explorerText = targetConfig.explorerText const environment = config.get('ENVIRONMENT').toLowerCase() @@ -41,6 +47,39 @@ export const OpenExplorerPage = () => { if (!dclenv) { console.warn('Unknown ENVIRONMENT value for deep link:', environment, '— defaulting to org') } + + // Post the identity to the auth server on mount + useEffect(() => { + if (hasStartedPosting.current) return + hasStartedPosting.current = true + + const postCurrentIdentity = async () => { + const ethAddress = account?.toLowerCase() + if (!ethAddress) { + navigate(locations.login(), { replace: true }) + return + } + + const identity = localStorageGetIdentity(ethAddress) + if (!identity) { + console.warn('No identity found in localStorage for', ethAddress) + navigate(locations.login(), { replace: true }) + return + } + + try { + const httpClient = createAuthServerHttpClient() + const response = await httpClient.postIdentity(identity, { isMobile: false }) + setIdentityId(response.identityId) + } catch (err) { + handleError(err, 'Error posting identity to auth server') + setError(true) + } + } + + postCurrentIdentity() + }, [account, navigate]) + const deepLinkUrl = identityId ? `decentraland://?dclenv=${dclenv ?? 'org'}&signin=${identityId}` : null const attemptDeepLink = useCallback(async () => { @@ -55,16 +94,9 @@ export const OpenExplorerPage = () => { navigate(locations.login(), { replace: true }) }, [navigate]) - // Redirect to login if no identityId was provided - useEffect(() => { - if (!identityId) { - navigate(locations.login(), { replace: true }) - } - }, [identityId, navigate]) - - // Countdown and auto-launch deep link + // Countdown and auto-launch deep link after identity is posted useEffect(() => { - if (deepLinkFailed || !identityId) return + if (!identityId || deepLinkFailed) return const interval = setInterval(() => { setCountdown(prev => { @@ -78,9 +110,43 @@ export const OpenExplorerPage = () => { }, 1000) return () => clearInterval(interval) - }, [deepLinkFailed, attemptDeepLink, identityId]) + }, [identityId, deepLinkFailed, attemptDeepLink]) - if (!identityId) return null + if (error) { + return ( + + + + + + {t('mobile_auth.could_not_open', { explorerText })} + + + + + + + ) + } + + if (!identityId) { + return ( + + + + + + {t('connection_layout.validating_sign_in')} + + + + + + + ) + } return ( diff --git a/src/shared/locations.ts b/src/shared/locations.ts index b052b67b..1efc4f08 100644 --- a/src/shared/locations.ts +++ b/src/shared/locations.ts @@ -73,7 +73,7 @@ const locations = { `/avatar-setup${redirectTo ? `?redirectTo=${encodeURIComponent(redirectTo)}` : ''}${ referrer ? `${redirectTo ? '&' : '?'}referrer=${encodeURIComponent(referrer)}` : '' }`, - openExplorer: (identityId: string) => `/open-explorer?identityId=${encodeURIComponent(identityId)}`, + openExplorer: () => '/open-explorer', mobile: (provider?: string) => `/mobile${provider ? `?provider=${encodeURIComponent(provider)}` : ''}`, mobileCallback: () => '/mobile/callback' } From bce1435718ef4bf9ae5357aa94e84c84f39deafe Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Thu, 9 Apr 2026 10:35:01 -0300 Subject: [PATCH 10/13] feat: add retry and go back buttons when deep link fails When the explorer deep link fails (user dismisses the browser prompt or app not installed), show two options instead of just "try again": - Retry: re-attempts the deep link with a fresh countdown - Go back to login: navigates to /login for a different login method The postIdentity error state only shows "go back to login" since there is no deep link to retry. --- .../OpenExplorerPage.spec.tsx | 55 +++++++++++++++++-- .../OpenExplorerPage/OpenExplorerPage.tsx | 16 ++++-- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx index be9356b3..b49f71f3 100644 --- a/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx +++ b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention -- mock shapes must match exported names */ import { act, render, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { AuthIdentity } from '@dcl/crypto' @@ -179,7 +178,7 @@ describe('OpenExplorerPage', () => { mockLaunchDeepLink.mockResolvedValue(false) }) - it('should show a try again button', async () => { + it('should show both retry and go back buttons', async () => { const { container, getByTestId } = render() await waitFor(() => { @@ -191,7 +190,53 @@ describe('OpenExplorerPage', () => { }) await waitFor(() => { - expect(getByTestId('open-explorer-try-again-button')).toBeTruthy() + expect(getByTestId('open-explorer-retry-button')).toBeTruthy() + expect(getByTestId('open-explorer-go-back-button')).toBeTruthy() + }) + }) + + it('should navigate to login when go back is clicked', async () => { + const { container, getByTestId } = render() + + await waitFor(() => { + expect(container.textContent).toContain('Opening Decentraland app in') + }) + + // Advance timers and flush the async launchDeepLink promise + await act(async () => { + jest.advanceTimersByTime(3000) + }) + + await waitFor(() => { + expect(getByTestId('open-explorer-go-back-button')).toBeTruthy() + }) + + getByTestId('open-explorer-go-back-button').click() + + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/login'), { replace: true }) + }) + + it('should restart the countdown when retry is clicked', async () => { + mockLaunchDeepLink.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + const { container, getByTestId } = render() + + await waitFor(() => { + expect(container.textContent).toContain('Opening Decentraland app in') + }) + + act(() => { + jest.advanceTimersByTime(3000) + }) + + await waitFor(() => { + expect(getByTestId('open-explorer-retry-button')).toBeTruthy() + }) + + await user.click(getByTestId('open-explorer-retry-button')) + + await waitFor(() => { + expect(container.textContent).toContain('Opening Decentraland app in 3...') }) }) }) @@ -215,11 +260,11 @@ describe('OpenExplorerPage', () => { mockPostIdentity.mockRejectedValue(new Error('Server error')) }) - it('should show an error state with try again', async () => { + it('should show an error state with a go back to login button', async () => { const { getByTestId } = render() await waitFor(() => { - expect(getByTestId('open-explorer-try-again-button')).toBeTruthy() + expect(getByTestId('open-explorer-go-back-button')).toBeTruthy() }) }) }) diff --git a/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx index 355095e8..3fbcae2b 100644 --- a/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx +++ b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx @@ -90,7 +90,12 @@ export const OpenExplorerPage = () => { } }, [deepLinkUrl]) - const handleTryAgain = useCallback(() => { + const handleRetryDeepLink = useCallback(() => { + setDeepLinkFailed(false) + setCountdown(COUNTDOWN_SECONDS) + }, []) + + const handleGoBackToLogin = useCallback(() => { navigate(locations.login(), { replace: true }) }, [navigate]) @@ -121,8 +126,8 @@ export const OpenExplorerPage = () => { {t('mobile_auth.could_not_open', { explorerText })} - @@ -158,9 +163,12 @@ export const OpenExplorerPage = () => { <> {t('mobile_auth.could_not_open', { explorerText })} - + ) : ( From 517cfad7602d5b4ce8282c4ebe0ae0c21a5e653e Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Thu, 9 Apr 2026 11:20:45 -0300 Subject: [PATCH 11/13] refactor: bypass LoginPage entirely for social OAuth auto-login Add LoginRouteGuard at the route level that checks the loginMethod query param synchronously. For social providers (google, discord, apple, x), it renders SocialAutoLoginRedirect directly without ever mounting LoginPage. For all other cases, LoginPage is lazy-loaded via React.lazy + Suspense. This avoids initializing LoginPage's 24+ hooks and waiting for feature flags when the only action is to redirect to an OAuth provider. --- .../Pages/LoginPage/LoginPage.spec.tsx | 4 - src/components/Pages/LoginPage/LoginPage.tsx | 21 +--- .../Pages/LoginPage/LoginRouteGuard.spec.tsx | 103 ++++++++++++++++++ .../Pages/LoginPage/LoginRouteGuard.tsx | 40 +++++++ src/components/Pages/LoginPage/index.ts | 3 +- src/main.tsx | 4 +- 6 files changed, 150 insertions(+), 25 deletions(-) create mode 100644 src/components/Pages/LoginPage/LoginRouteGuard.spec.tsx create mode 100644 src/components/Pages/LoginPage/LoginRouteGuard.tsx diff --git a/src/components/Pages/LoginPage/LoginPage.spec.tsx b/src/components/Pages/LoginPage/LoginPage.spec.tsx index 44f4ea23..07c41f24 100644 --- a/src/components/Pages/LoginPage/LoginPage.spec.tsx +++ b/src/components/Pages/LoginPage/LoginPage.spec.tsx @@ -151,10 +151,6 @@ jest.mock('./ConfirmingLogin', () => ({ ConfirmingLogin: () =>
})) -jest.mock('./SocialAutoLoginRedirect', () => ({ - SocialAutoLoginRedirect: () =>
-})) - jest.mock('decentraland-connect', () => ({ connection: { disconnect: jest.fn().mockResolvedValue(undefined) diff --git a/src/components/Pages/LoginPage/LoginPage.tsx b/src/components/Pages/LoginPage/LoginPage.tsx index ce0f86eb..518d9aee 100644 --- a/src/components/Pages/LoginPage/LoginPage.tsx +++ b/src/components/Pages/LoginPage/LoginPage.tsx @@ -18,8 +18,7 @@ import ImageNew6 from '../../../assets/images/background/image-new6.webp' import { useAfterLoginRedirection } from '../../../hooks/redirection' import { useTargetConfig } from '../../../hooks/targetConfig' import { useAnalytics } from '../../../hooks/useAnalytics' -import { VALID_LOGIN_METHODS, mapLoginMethodToConnectionOption, useAutoLogin } from '../../../hooks/useAutoLogin' -import type { LoginMethod } from '../../../hooks/useAutoLogin' +import { useAutoLogin } from '../../../hooks/useAutoLogin' import { useEnsureProfile } from '../../../hooks/useEnsureProfile' import { ConnectionType } from '../../../modules/analytics/types' import { createAuthServerHttpClient } from '../../../shared/auth' @@ -39,7 +38,6 @@ import { EmailLoginModal } from '../../EmailLoginModal' import { EmailLoginResult } from '../../EmailLoginModal/EmailLoginModal.types' import { FeatureFlagsContext, FeatureFlagsKeys } from '../../FeatureFlagsProvider' import { ConfirmingLogin } from './ConfirmingLogin' -import { SocialAutoLoginRedirect } from './SocialAutoLoginRedirect' import { connectToProvider, connectToSocialProvider, @@ -374,19 +372,10 @@ export const LoginPage = () => { setShowEmailLoginModal(true) }, []) - // When loginMethod is a social provider, skip the full login UI and redirect immediately to OAuth. - // The full page (backgrounds, connection options, modals) is unnecessary since we just redirect away. - const socialAutoLoginType = useMemo(() => { - const param = new URLSearchParams(window.location.search).get('loginMethod')?.toLowerCase() - if (!param || !VALID_LOGIN_METHODS.includes(param as LoginMethod)) return null - const connectionOption = mapLoginMethodToConnectionOption(param as LoginMethod) - return isSocialLogin(connectionOption) ? connectionOption : null - }, []) - // Use the auto-login hook to handle loginMethod URL parameter for non-social methods. - // Disabled when socialAutoLoginType is set to avoid a duplicate connectToSocialProvider call. + // Social login methods are handled by LoginRouteGuard before this component mounts. useAutoLogin({ - isReady: flagInitialized && !socialAutoLoginType, + isReady: flagInitialized, onConnect: handleOnConnect }) @@ -450,10 +439,6 @@ export const LoginPage = () => { ) } - if (socialAutoLoginType) { - return - } - const backgroundImages = NEW_USER_BACKGROUND_IMAGES return (
diff --git a/src/components/Pages/LoginPage/LoginRouteGuard.spec.tsx b/src/components/Pages/LoginPage/LoginRouteGuard.spec.tsx new file mode 100644 index 00000000..9b380e82 --- /dev/null +++ b/src/components/Pages/LoginPage/LoginRouteGuard.spec.tsx @@ -0,0 +1,103 @@ +import { render } from '@testing-library/react' +import { LoginRouteGuard } from './LoginRouteGuard' + +jest.mock('./SocialAutoLoginRedirect', () => ({ + SocialAutoLoginRedirect: ({ connectionType }: { connectionType: string }) => ( +
+ ) +})) + +jest.mock('./LoginPage', () => ({ + LoginPage: () =>
+})) + +jest.mock('decentraland-ui2', () => ({ + CircularProgress: () =>
+})) + +const mockSearchParams = (params: string) => { + Object.defineProperty(window, 'location', { + value: { search: params, pathname: '/login', href: `http://localhost/login${params}` }, + writable: true, + configurable: true + }) +} + +describe('LoginRouteGuard', () => { + afterEach(() => { + mockSearchParams('') + }) + + describe('when loginMethod is a social provider', () => { + it('should render SocialAutoLoginRedirect for google', async () => { + mockSearchParams('?loginMethod=google') + const { getByTestId } = render() + + expect(getByTestId('social-auto-login-redirect')).toBeTruthy() + expect(getByTestId('social-auto-login-redirect').getAttribute('data-connection-type')).toBe('google') + }) + + it('should render SocialAutoLoginRedirect for discord', async () => { + mockSearchParams('?loginMethod=discord') + const { getByTestId } = render() + + expect(getByTestId('social-auto-login-redirect').getAttribute('data-connection-type')).toBe('discord') + }) + + it('should render SocialAutoLoginRedirect for apple', async () => { + mockSearchParams('?loginMethod=apple') + const { getByTestId } = render() + + expect(getByTestId('social-auto-login-redirect').getAttribute('data-connection-type')).toBe('apple') + }) + + it('should render SocialAutoLoginRedirect for x', async () => { + mockSearchParams('?loginMethod=x') + const { getByTestId } = render() + + expect(getByTestId('social-auto-login-redirect').getAttribute('data-connection-type')).toBe('x') + }) + + it('should not render LoginPage', async () => { + mockSearchParams('?loginMethod=google') + const { queryByTestId } = render() + + expect(queryByTestId('login-page')).toBeNull() + }) + }) + + describe('when loginMethod is a non-social provider', () => { + it('should render LoginPage for metamask', async () => { + mockSearchParams('?loginMethod=metamask') + const { findByTestId, queryByTestId } = render() + + expect(await findByTestId('login-page')).toBeTruthy() + expect(queryByTestId('social-auto-login-redirect')).toBeNull() + }) + + it('should render LoginPage for email', async () => { + mockSearchParams('?loginMethod=email') + const { findByTestId } = render() + + expect(await findByTestId('login-page')).toBeTruthy() + }) + }) + + describe('when no loginMethod is provided', () => { + it('should render LoginPage', async () => { + mockSearchParams('') + const { findByTestId } = render() + + expect(await findByTestId('login-page')).toBeTruthy() + }) + }) + + describe('when loginMethod is invalid', () => { + it('should render LoginPage', async () => { + mockSearchParams('?loginMethod=invalid') + const { findByTestId } = render() + + expect(await findByTestId('login-page')).toBeTruthy() + }) + }) +}) diff --git a/src/components/Pages/LoginPage/LoginRouteGuard.tsx b/src/components/Pages/LoginPage/LoginRouteGuard.tsx new file mode 100644 index 00000000..39edc70e --- /dev/null +++ b/src/components/Pages/LoginPage/LoginRouteGuard.tsx @@ -0,0 +1,40 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention +import React, { Suspense } from 'react' +import { CircularProgress } from 'decentraland-ui2' +import { ConnectionOptionType } from '../../Connection/Connection.types' +import { SocialAutoLoginRedirect } from './SocialAutoLoginRedirect' + +const LazyLoginPage = React.lazy(() => import('./LoginPage').then(m => ({ default: m.LoginPage }))) + +const SOCIAL_LOGIN_METHODS: Record = { + google: ConnectionOptionType.GOOGLE, + discord: ConnectionOptionType.DISCORD, + apple: ConnectionOptionType.APPLE, + x: ConnectionOptionType.X +} + +function getSocialAutoLoginType(): ConnectionOptionType | null { + const param = new URLSearchParams(window.location.search).get('loginMethod')?.toLowerCase() + if (!param) return null + return SOCIAL_LOGIN_METHODS[param] ?? null +} + +export const LoginRouteGuard = () => { + const socialAutoLoginType = getSocialAutoLoginType() + + if (socialAutoLoginType) { + return + } + + return ( + + +
+ } + > + + + ) +} diff --git a/src/components/Pages/LoginPage/index.ts b/src/components/Pages/LoginPage/index.ts index f5de7eb0..c0c25a30 100644 --- a/src/components/Pages/LoginPage/index.ts +++ b/src/components/Pages/LoginPage/index.ts @@ -1 +1,2 @@ -export * from './LoginPage' +export { LoginRouteGuard } from './LoginRouteGuard' +export { LoginPage } from './LoginPage' diff --git a/src/main.tsx b/src/main.tsx index b6e02ad8..a9a92d97 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -15,7 +15,7 @@ import { AvatarSetupPage } from './components/Pages/AvatarSetupPage/AvatarSetupP import Intercom from './components/Intercom' import { CallbackPage } from './components/Pages/CallbackPage' import { InvalidRedirectionPage } from './components/Pages/InvalidRedirectionPage' -import { LoginPage } from './components/Pages/LoginPage' +import { LoginRouteGuard } from './components/Pages/LoginPage' import { MobileAuthPage } from './components/Pages/MobileAuthPage' import { MobileCallbackPage } from './components/Pages/MobileCallbackPage' import { OpenExplorerPage } from './components/Pages/OpenExplorerPage' @@ -71,7 +71,7 @@ const SiteRoutes = () => { return ( - + From 7b301746feb72fda960418b997f0e04be4782b20 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Thu, 9 Apr 2026 11:26:35 -0300 Subject: [PATCH 12/13] fix: wait for feature flags before starting OAuth redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SocialAutoLoginRedirect now waits for FeatureFlagsContext to be initialized before calling connectToSocialProvider. Without this, the MAGIC_TEST flag was always read as undefined because the component mounted before flags finished loading — causing the production Magic API key to be used even when testing was enabled. --- src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx index fd1cd601..10adc3fb 100644 --- a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx +++ b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx @@ -19,7 +19,7 @@ type Props = { export const SocialAutoLoginRedirect = ({ connectionType }: Props) => { const { t } = useTranslation() - const { flags } = useContext(FeatureFlagsContext) + const { flags, initialized } = useContext(FeatureFlagsContext) const { url: redirectTo } = useAfterLoginRedirection() const { trackLoginClick } = useAnalytics() const hasStarted = useRef(false) @@ -40,10 +40,10 @@ export const SocialAutoLoginRedirect = ({ connectionType }: Props) => { }, [connectionType, flags[FeatureFlagsKeys.MAGIC_TEST], redirectTo, trackLoginClick]) useEffect(() => { - if (hasStarted.current) return + if (!initialized || hasStarted.current) return hasStarted.current = true startRedirect() - }, [startRedirect]) + }, [initialized, startRedirect]) // On failure, navigate to the login page without loginMethod to show the full UI. // A simple reload would loop since the URL still contains loginMethod. From e6f0bfc5d39e90025a6c317b4844c03e4bd1f1b1 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Thu, 9 Apr 2026 11:46:51 -0300 Subject: [PATCH 13/13] test: cover uninitialized flags and missing identity paths - SocialAutoLoginRedirect: test that connectToSocialProvider is not called when feature flags are not yet initialized - OpenExplorerPage: test that navigating to login occurs when identity is not found in localStorage --- .../SocialAutoLoginRedirect.spec.tsx | 55 +++++++++++++------ .../OpenExplorerPage.spec.tsx | 14 +++++ 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx index c16b8ac4..a6ba175b 100644 --- a/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx +++ b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/naming-convention -- mock shapes must match exported names */ +/* eslint-disable @typescript-eslint/naming-convention, import/order -- mock shapes must match exported names */ import { render, waitFor } from '@testing-library/react' import { ConnectionOptionType } from '../../Connection/Connection.types' import { SocialAutoLoginRedirect } from './SocialAutoLoginRedirect' @@ -36,13 +36,11 @@ jest.mock('../../FeatureFlagsProvider', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { createContext } = require('react') return { - FeatureFlagsContext: { - ...createContext({ - flags: { 'dapps-magic-dev-test': false }, - variants: {}, - initialized: true - }) - }, + FeatureFlagsContext: createContext({ + flags: { 'dapps-magic-dev-test': false }, + variants: {}, + initialized: true + }), FeatureFlagsKeys: { MAGIC_TEST: 'dapps-magic-dev-test' } } }) @@ -76,8 +74,20 @@ jest.mock('decentraland-ui2', () => ({ // --- Helpers --- -const renderComponent = (connectionType: ConnectionOptionType) => { - return render() +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { FeatureFlagsContext } = require('../../FeatureFlagsProvider') + +interface RenderOptions { + connectionType: ConnectionOptionType + initialized?: boolean +} + +const renderComponent = ({ connectionType, initialized = true }: RenderOptions) => { + return render( + + + + ) } // --- Tests --- @@ -89,22 +99,22 @@ describe('SocialAutoLoginRedirect', () => { describe('when rendered with a social connection type', () => { it('should render the animated background', () => { - const { getByTestId } = renderComponent(ConnectionOptionType.GOOGLE) + const { getByTestId } = renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) expect(getByTestId('animated-background')).toBeTruthy() }) it('should show a redirecting message with the provider name', () => { - const { container } = renderComponent(ConnectionOptionType.GOOGLE) + const { container } = renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) expect(container.textContent).toContain('Redirecting to Google...') }) it('should render a loading spinner', () => { - const { getByTestId } = renderComponent(ConnectionOptionType.GOOGLE) + const { getByTestId } = renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) expect(getByTestId('loading-spinner')).toBeTruthy() }) it('should track the login click', async () => { - renderComponent(ConnectionOptionType.GOOGLE) + renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) await waitFor(() => { expect(mockTrackLoginClick).toHaveBeenCalledWith({ @@ -115,7 +125,7 @@ describe('SocialAutoLoginRedirect', () => { }) it('should call connectToSocialProvider with the correct arguments', async () => { - renderComponent(ConnectionOptionType.GOOGLE) + renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) await waitFor(() => { expect(mockConnectToSocialProvider).toHaveBeenCalledWith(ConnectionOptionType.GOOGLE, false, 'https://decentraland.org/play') @@ -123,7 +133,7 @@ describe('SocialAutoLoginRedirect', () => { }) it('should not call connectToSocialProvider twice', async () => { - renderComponent(ConnectionOptionType.DISCORD) + renderComponent({ connectionType: ConnectionOptionType.DISCORD }) await waitFor(() => { expect(mockConnectToSocialProvider).toHaveBeenCalledTimes(1) @@ -131,6 +141,17 @@ describe('SocialAutoLoginRedirect', () => { }) }) + describe('when feature flags are not yet initialized', () => { + it('should not call connectToSocialProvider', async () => { + renderComponent({ connectionType: ConnectionOptionType.GOOGLE, initialized: false }) + + // Give it time to potentially fire + await new Promise(resolve => setTimeout(resolve, 50)) + + expect(mockConnectToSocialProvider).not.toHaveBeenCalled() + }) + }) + describe('when connectToSocialProvider throws an error', () => { const originalHref = window.location.href @@ -144,7 +165,7 @@ describe('SocialAutoLoginRedirect', () => { }) it('should navigate to the login page without loginMethod', async () => { - renderComponent(ConnectionOptionType.GOOGLE) + renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) await waitFor(() => { expect(window.location.href).toContain('/login') diff --git a/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx index b49f71f3..1691ed9a 100644 --- a/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx +++ b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx @@ -255,6 +255,20 @@ describe('OpenExplorerPage', () => { }) }) + describe('when identity is not in localStorage', () => { + beforeEach(() => { + ;(localStorageGetIdentity as jest.Mock).mockReturnValue(null) + }) + + it('should navigate to the login page', async () => { + render() + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/login'), { replace: true }) + }) + }) + }) + describe('when postIdentity fails', () => { beforeEach(() => { mockPostIdentity.mockRejectedValue(new Error('Server error'))