Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
263 changes: 263 additions & 0 deletions src/components/Pages/CallbackPage/CallbackPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="animated-background" />
}))

jest.mock('../../ConnectionModal/ConnectionLayout', () => ({
ConnectionLayout: ({ state }: { state: string }) => <div data-testid="connection-layout" data-state={state} />
}))

jest.mock('./CallbackPage.styled', () => {
const Div = ({ children, ...props }: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => (
<div {...props}>{children}</div>
)
return { Container: Div, Wrapper: Div }
})

jest.mock('../MobileCallbackPage/MobileCallbackPage', () => ({
MobileCallbackPage: () => <div data-testid="mobile-callback" />
}))

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<string, boolean>
redirectUrl?: string
}

const renderWithProviders = ({ flags = {}, redirectUrl = 'https://decentraland.org/' }: RenderOptions = {}) => {
mockRedirectUrl = redirectUrl
return render(
<BrowserRouter>
<FeatureFlagsContext.Provider value={{ flags, variants: {}, initialized: true }}>
<CallbackPage />
</FeatureFlagsContext.Provider>
</BrowserRouter>
)
}

// --- 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 })
})
})
})
})
21 changes: 19 additions & 2 deletions src/components/Pages/CallbackPage/CallbackPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand All @@ -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 () => {
Expand Down
3 changes: 2 additions & 1 deletion src/components/Pages/LoginPage/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading