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'
}