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..1ebd12eb --- /dev/null +++ b/src/components/Pages/CallbackPage/CallbackPage.spec.tsx @@ -0,0 +1,263 @@ +/* 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 { CallbackPage } from './CallbackPage' + +// --- Mocks --- + +const mockNavigate = jest.fn() +jest.mock('../../../hooks/navigation', () => ({ + useNavigateWithSearchParams: () => mockNavigate +})) + +const mockRedirect = jest.fn() +let mockRedirectUrl = 'https://decentraland.org/' +jest.mock('../../../hooks/redirection', () => ({ + useAfterLoginRedirection: () => ({ url: mockRedirectUrl, 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 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 } + }) +})) + +const mockMarkReturningUser = jest.fn() +jest.mock('../../../shared/onboarding/markReturningUser', () => ({ + markReturningUser: (...args: unknown[]) => mockMarkReturningUser(...args) +})) + +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('../../AnimatedBackground', () => ({ + AnimatedBackground: () =>
+})) + +jest.mock('../../ConnectionModal/ConnectionLayout', () => ({ + ConnectionLayout: ({ state }: { state: string }) =>
+})) + +jest.mock('./CallbackPage.styled', () => { + const Div = ({ children, ...props }: React.HTMLAttributes & { children?: React.ReactNode }) => ( +
{children}
+ ) + return { Container: Div, Wrapper: Div } +}) + +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 +})) + +// --- Helpers --- + +// 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 + +interface RenderOptions { + flags?: Record + redirectUrl?: string +} + +const renderWithProviders = ({ flags = {}, redirectUrl = 'https://decentraland.org/' }: RenderOptions = {}) => { + mockRedirectUrl = redirectUrl + 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', () => { + 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'), { replace: true }) + }) + }) + + it('should not call redirect', async () => { + renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/open-explorer'), { replace: true }) + }) + + expect(mockRedirect).not.toHaveBeenCalled() + }) + + it('should mark the user as returning', async () => { + renderWithProviders({ flags: { 'dapps-open-explorer-after-login': true } }) + + await waitFor(() => { + expect(mockMarkReturningUser).toHaveBeenCalledWith('0xTestAccount') + }) + }) + }) + + describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is disabled', () => { + it('should not navigate to the open explorer page', async () => { + renderWithProviders() + + await waitFor(() => { + expect(mockRedirect).toHaveBeenCalled() + }) + + expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining('/open-explorer'), expect.anything()) + }) + + it('should call redirect', async () => { + renderWithProviders() + + await waitFor(() => { + expect(mockRedirect).toHaveBeenCalled() + }) + }) + }) + + describe('when the OPEN_EXPLORER_AFTER_LOGIN flag is enabled and redirectTo has an explicit path', () => { + it('should redirect instead of navigating to open explorer', async () => { + renderWithProviders({ + flags: { 'dapps-open-explorer-after-login': true }, + redirectUrl: 'https://decentraland.org/marketplace' + }) + + await waitFor(() => { + expect(mockRedirect).toHaveBeenCalled() + }) + + expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining('/open-explorer'), expect.anything()) + }) + }) + + 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(expect.stringContaining('/login'), { replace: true }) + }) + }) + }) +}) diff --git a/src/components/Pages/CallbackPage/CallbackPage.tsx b/src/components/Pages/CallbackPage/CallbackPage.tsx index 5bcc7507..8cd59de3 100644 --- a/src/components/Pages/CallbackPage/CallbackPage.tsx +++ b/src/components/Pages/CallbackPage/CallbackPage.tsx @@ -13,8 +13,8 @@ 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' @@ -101,6 +101,13 @@ const DesktopCallbackPage = () => { type: ConnectionType.WEB2 }) + const hasExplicitRedirect = new URL(redirectTo, window.location.origin).pathname !== '/' + if (flags[FeatureFlagsKeys.OPEN_EXPLORER_AFTER_LOGIN] && !hasExplicitRedirect) { + markReturningUser(connectionData.account ?? '') + navigate(locations.openExplorer(), { replace: true }) + return + } + const account = connectionData.account ?? '' if (targetConfig && !targetConfig.skipSetup && account) { @@ -117,7 +124,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 () => { diff --git a/src/components/Pages/LoginPage/LoginPage.tsx b/src/components/Pages/LoginPage/LoginPage.tsx index 606c1ac7..518d9aee 100644 --- a/src/components/Pages/LoginPage/LoginPage.tsx +++ b/src/components/Pages/LoginPage/LoginPage.tsx @@ -372,7 +372,8 @@ export const LoginPage = () => { setShowEmailLoginModal(true) }, []) - // Use the auto-login hook to handle loginMethod URL parameter + // Use the auto-login hook to handle loginMethod URL parameter for non-social methods. + // Social login methods are handled by LoginRouteGuard before this component mounts. useAutoLogin({ isReady: flagInitialized, onConnect: handleOnConnect 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/SocialAutoLoginRedirect.spec.tsx b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx new file mode 100644 index 00000000..a6ba175b --- /dev/null +++ b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.spec.tsx @@ -0,0 +1,176 @@ +/* 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' + +const mockConnectToSocialProvider = jest.fn() +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: () => ({ + 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('../../Pages/CallbackPage/CallbackPage.styled', () => { + const Div = ({ children, ...props }: React.HTMLAttributes & { children?: React.ReactNode }) => ( +
{children}
+ ) + return { Container: Div, Wrapper: Div } +}) + +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: () =>
+})) + +// --- Helpers --- + +// 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 --- + +describe('SocialAutoLoginRedirect', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe('when rendered with a social connection type', () => { + it('should render the animated background', () => { + const { getByTestId } = renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) + expect(getByTestId('animated-background')).toBeTruthy() + }) + + it('should show a redirecting message with the provider name', () => { + const { container } = renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) + expect(container.textContent).toContain('Redirecting to Google...') + }) + + it('should render a loading spinner', () => { + const { getByTestId } = renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) + expect(getByTestId('loading-spinner')).toBeTruthy() + }) + + it('should track the login click', async () => { + renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) + + await waitFor(() => { + expect(mockTrackLoginClick).toHaveBeenCalledWith({ + method: ConnectionOptionType.GOOGLE, + type: 'web2' + }) + }) + }) + + it('should call connectToSocialProvider with the correct arguments', async () => { + renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) + + await waitFor(() => { + expect(mockConnectToSocialProvider).toHaveBeenCalledWith(ConnectionOptionType.GOOGLE, false, 'https://decentraland.org/play') + }) + }) + + it('should not call connectToSocialProvider twice', async () => { + renderComponent({ connectionType: ConnectionOptionType.DISCORD }) + + await waitFor(() => { + expect(mockConnectToSocialProvider).toHaveBeenCalledTimes(1) + }) + }) + }) + + 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 + + beforeEach(() => { + mockConnectToSocialProvider.mockRejectedValue(new Error('Magic SDK failed')) + Object.defineProperty(window, 'location', { + value: { ...window.location, href: originalHref }, + writable: true, + configurable: true + }) + }) + + it('should navigate to the login page without loginMethod', async () => { + renderComponent({ connectionType: ConnectionOptionType.GOOGLE }) + + await waitFor(() => { + 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 new file mode 100644 index 00000000..10adc3fb --- /dev/null +++ b/src/components/Pages/LoginPage/SocialAutoLoginRedirect.tsx @@ -0,0 +1,72 @@ +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 { locations } from '../../../shared/locations' +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 = { + connectionType: ConnectionOptionType +} + +export const SocialAutoLoginRedirect = ({ connectionType }: Props) => { + const { t } = useTranslation() + const { flags, initialized } = useContext(FeatureFlagsContext) + const { url: redirectTo } = useAfterLoginRedirection() + const { trackLoginClick } = useAnalytics() + const hasStarted = useRef(false) + const [failed, setFailed] = useState(false) + + const startRedirect = useCallback(async () => { + try { + trackLoginClick({ + method: connectionType, + type: ConnectionType.WEB2 + }) + + 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(() => { + if (!initialized || hasStarted.current) return + hasStarted.current = true + 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. + useEffect(() => { + if (failed) { + window.location.href = locations.login() + } + }, [failed]) + + const providerName = connectionOptionTitles[connectionType] + + return ( + + + + + + {t('social_auto_login.redirecting_to', { provider: providerName })} + + + + + + + ) +} 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/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx new file mode 100644 index 00000000..1691ed9a --- /dev/null +++ b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.spec.tsx @@ -0,0 +1,300 @@ +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() +jest.mock('../RequestPage/utils', () => ({ + launchDeepLink: (...args: unknown[]) => mockLaunchDeepLink(...args) +})) + +jest.mock('../../../modules/config', () => ({ + config: { + get: (key: string) => { + if (key === 'ENVIRONMENT') return 'production' + return '' + } + } +})) + +const mockNavigate = jest.fn() +jest.mock('../../../hooks/navigation', () => ({ + useNavigateWithSearchParams: () => mockNavigate +})) + +jest.mock('../../../hooks/targetConfig', () => ({ + useTargetConfig: () => [ + { + explorerText: 'Decentraland app' + }, + 'default' + ] +})) + +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) => { + 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 === 'connection_layout.validating_sign_in') return 'Verifying...' + if (key === 'common.try_again') return 'Try again' + return key + } + }) +})) + +jest.mock('../../AnimatedBackground', () => ({ + AnimatedBackground: () =>
+})) + +jest.mock('./OpenExplorerPage.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: () =>
+})) + +// --- 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) + ;(localStorageGetIdentity as jest.Mock).mockReturnValue(mockIdentity) + mockPostIdentity.mockResolvedValue({ identityId: 'test-id-123' }) + }) + + afterEach(() => { + jest.useRealTimers() + jest.clearAllMocks() + }) + + 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 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 () => { + 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) + }) + + await waitFor(() => { + expect(mockLaunchDeepLink).toHaveBeenCalledWith('decentraland://?dclenv=org&signin=test-id-123') + }) + }) + }) + + describe('when the deep link fails', () => { + beforeEach(() => { + mockLaunchDeepLink.mockResolvedValue(false) + }) + + it('should show both retry and go back buttons', async () => { + 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() + 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...') + }) + }) + }) + + describe('when no account is available', () => { + beforeEach(() => { + mockAccount = undefined + }) + + it('should navigate to the login page', async () => { + render() + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/login'), { replace: true }) + }) + }) + }) + + 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')) + }) + + it('should show an error state with a go back to login button', async () => { + const { getByTestId } = render() + + await waitFor(() => { + expect(getByTestId('open-explorer-go-back-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 { container, getByTestId } = render() + + await waitFor(() => { + expect(container.textContent).toContain('Opening Decentraland app in') + }) + + 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/OpenExplorerPage/OpenExplorerPage.tsx b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx new file mode 100644 index 00000000..3fbcae2b --- /dev/null +++ b/src/components/Pages/OpenExplorerPage/OpenExplorerPage.tsx @@ -0,0 +1,193 @@ +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, + ConnectionTitle, + DecentralandLogo, + ErrorButtonContainer, + ProgressContainer +} from '../../ConnectionModal/ConnectionLayout.styled' +import { launchDeepLink } from '../RequestPage/utils' +import { Container, Wrapper } from './OpenExplorerPage.styled' + +const COUNTDOWN_SECONDS = 3 + +const ENVIRONMENT_TO_DCLENV: Record = { + development: 'zone', + staging: 'today', + production: 'org' +} + +export const OpenExplorerPage = () => { + const { t } = useTranslation() + const navigate = useNavigateWithSearchParams() + 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 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') + } + + // 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 () => { + if (!deepLinkUrl) return + const wasLaunched = await launchDeepLink(deepLinkUrl) + if (!wasLaunched) { + setDeepLinkFailed(true) + } + }, [deepLinkUrl]) + + const handleRetryDeepLink = useCallback(() => { + setDeepLinkFailed(false) + setCountdown(COUNTDOWN_SECONDS) + }, []) + + const handleGoBackToLogin = useCallback(() => { + navigate(locations.login(), { replace: true }) + }, [navigate]) + + // Countdown and auto-launch deep link after identity is posted + useEffect(() => { + if (!identityId || deepLinkFailed) return + + const interval = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + clearInterval(interval) + attemptDeepLink() + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(interval) + }, [identityId, deepLinkFailed, attemptDeepLink]) + + if (error) { + return ( + + + + + + {t('mobile_auth.could_not_open', { explorerText })} + + + + + + + ) + } + + if (!identityId) { + return ( + + + + + + {t('connection_layout.validating_sign_in')} + + + + + + + ) + } + + 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/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/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/main.tsx b/src/main.tsx index 6f8a7437..a9a92d97 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -15,9 +15,10 @@ 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' import { FeatureFlagsProvider } from './components/FeatureFlagsProvider' import { ConnectionProvider } from './shared/connection' import { config } from './modules/config' @@ -70,7 +71,7 @@ const SiteRoutes = () => { return ( - + @@ -84,6 +85,7 @@ const SiteRoutes = () => { } /> ) : null} + 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}...", 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}...", diff --git a/src/shared/locations.ts b/src/shared/locations.ts index e311e4cd..1efc4f08 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: () => '/open-explorer', mobile: (provider?: string) => `/mobile${provider ? `?provider=${encodeURIComponent(provider)}` : ''}`, mobileCallback: () => '/mobile/callback' }