diff --git a/.changeset/plenty-dolls-pick.md b/.changeset/plenty-dolls-pick.md new file mode 100644 index 00000000000..34031d577e6 --- /dev/null +++ b/.changeset/plenty-dolls-pick.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +Centralize redirect concerns for SignIn diff --git a/integration/templates/next-app-router/next.config.js b/integration/templates/next-app-router/next.config.js index e47b0b46969..05a4a80e6ab 100644 --- a/integration/templates/next-app-router/next.config.js +++ b/integration/templates/next-app-router/next.config.js @@ -1,9 +1,19 @@ +const path = require('path'); + /** @type {import('next').NextConfig} */ const nextConfig = { eslint: { ignoreDuringBuilds: true, }, - outputFileTracingRoot: '/', + outputFileTracingRoot: path.resolve(__dirname, '../../../'), + webpack: config => { + // Exclude macOS .Trash directory and other system directories to prevent permission errors + config.watchOptions = { + ...config.watchOptions, + ignored: ['**/.Trash/**', '**/node_modules/**', '**/.git/**'], + }; + return config; + }, }; module.exports = nextConfig; diff --git a/integration/tests/session-tasks-multi-session.test.ts b/integration/tests/session-tasks-multi-session.test.ts index 7e923f6f957..3235d99fec1 100644 --- a/integration/tests/session-tasks-multi-session.test.ts +++ b/integration/tests/session-tasks-multi-session.test.ts @@ -61,6 +61,11 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // Create second user, to initiate a pending session // Don't resolve task and switch to active session afterwards await u.po.signIn.goTo(); + await u.page.waitForURL(/sign-in\/choose/); + await u.page.getByText('Add account').click(); + await u.page.waitForURL(/sign-in$/); + await u.po.signIn.waitForMounted(); + await u.po.signIn.getIdentifierInput().waitFor({ state: 'visible' }); await u.po.signIn.setIdentifier(user2.email); await u.po.signIn.continue(); await u.po.signIn.setPassword(user2.password); @@ -68,6 +73,11 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // Sign-in again back with active session await u.po.signIn.goTo(); + await u.page.waitForURL(/sign-in\/choose/); + await u.page.getByText('Add account').click(); + await u.page.waitForURL(/sign-in$/); + await u.po.signIn.waitForMounted(); + await u.po.signIn.getIdentifierInput().waitFor({ state: 'visible' }); await u.po.signIn.setIdentifier(user1.email); await u.po.signIn.continue(); await u.po.signIn.setPassword(user1.password); diff --git a/integration/tests/sign-in-single-session-mode.test.ts b/integration/tests/sign-in-single-session-mode.test.ts new file mode 100644 index 00000000000..821027d021d --- /dev/null +++ b/integration/tests/sign-in-single-session-mode.test.ts @@ -0,0 +1,79 @@ +import type { FakeUser } from '@clerk/testing/playwright'; +import { test } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { appConfigs } from '../presets'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +/** + * Tests for single-session mode behavior using the withBilling environment + * which is configured for single-session mode in the Clerk Dashboard. + */ +testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })( + 'sign in with active session in single-session mode @generic @nextjs', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + withPhoneNumber: true, + withUsername: true, + }); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + }); + + test('redirects to afterSignIn URL when visiting /sign-in with active session in single-session mode', async ({ + page, + context, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + + await u.po.signIn.goTo(); + await u.page.waitForAppUrl('/'); + await u.po.expect.toBeSignedIn(); + }); + + test('does NOT show account switcher in single-session mode', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/sign-in/choose'); + await u.page.waitForAppUrl('/'); + }); + + test('shows sign-in form when no active session in single-session mode', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.context().clearCookies(); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.page.waitForSelector('text=/email address|username|phone/i'); + await u.page.waitForURL(/sign-in$/); + }); + + test('can sign in normally when not already authenticated in single-session mode', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.context().clearCookies(); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + }); + }, +); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 6b0dd0cc8a9..1a54cda492c 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -10,43 +10,36 @@ import type { } from '@clerk/types'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { ERROR_CODES, SIGN_UP_MODES } from '@/core/constants'; +import { clerkInvalidFAPIResponse } from '@/core/errors'; +import type { SignInStartIdentifier } from '@/ui/common'; +import { buildSSOCallbackURL, getIdentifierControlDisplayValues, groupIdentifiers } from '@/ui/common'; +import { handleCombinedFlowTransfer } from '@/ui/components/SignIn/handleCombinedFlowTransfer'; +import { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey } from '@/ui/components/SignIn/shared'; +import { SignInAlternativePhoneCodePhoneNumberCard } from '@/ui/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard'; +import { SignInSocialButtons } from '@/ui/components/SignIn/SignInSocialButtons'; +import { + getPreferredAlternativePhoneChannel, + getPreferredAlternativePhoneChannelForCombinedFlow, + getSignUpAttributeFromIdentifier, +} from '@/ui/components/SignIn/utils'; +import { useCoreSignIn, useEnvironment, useSignInContext } from '@/ui/contexts'; +import { Col, descriptors, Flow, localizationKeys } from '@/ui/customizables'; +import { CaptchaElement } from '@/ui/elements/CaptchaElement'; import { Card } from '@/ui/elements/Card'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { Header } from '@/ui/elements/Header'; import { LoadingCard } from '@/ui/elements/LoadingCard'; import { SocialButtonsReversibleContainerWithDivider } from '@/ui/elements/ReversibleContainer'; +import { useLoadingStatus, useSignInRedirect } from '@/ui/hooks'; +import { useSupportEmail } from '@/ui/hooks/useSupportEmail'; +import { useRouter } from '@/ui/router'; import { handleError } from '@/ui/utils/errorHandler'; import { isMobileDevice } from '@/ui/utils/isMobileDevice'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { buildRequest, useFormControl } from '@/ui/utils/useFormControl'; - -import { ERROR_CODES, SIGN_UP_MODES } from '../../../core/constants'; -import { clerkInvalidFAPIResponse } from '../../../core/errors'; -import { getClerkQueryParam, removeClerkQueryParam } from '../../../utils'; -import type { SignInStartIdentifier } from '../../common'; -import { - buildSSOCallbackURL, - getIdentifierControlDisplayValues, - groupIdentifiers, - withRedirectToAfterSignIn, - withRedirectToSignInTask, -} from '../../common'; -import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; -import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; -import { CaptchaElement } from '../../elements/CaptchaElement'; -import { useLoadingStatus } from '../../hooks'; -import { useSupportEmail } from '../../hooks/useSupportEmail'; -import { useRouter } from '../../router'; -import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer'; -import { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey } from './shared'; -import { SignInAlternativePhoneCodePhoneNumberCard } from './SignInAlternativePhoneCodePhoneNumberCard'; -import { SignInSocialButtons } from './SignInSocialButtons'; -import { - getPreferredAlternativePhoneChannel, - getPreferredAlternativePhoneChannelForCombinedFlow, - getSignUpAttributeFromIdentifier, -} from './utils'; +import { getClerkQueryParam, removeClerkQueryParam } from '@/utils'; const useAutoFillPasskey = () => { const [isSupported, setIsSupported] = useState(false); @@ -87,6 +80,7 @@ function SignInStartInternal(): JSX.Element { const ctx = useSignInContext(); const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, navigateOnSetActive } = ctx; const supportEmail = useSupportEmail(); + const identifierAttributes = useMemo( () => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers), [userSettings.enabledFirstFactorIdentifiers], @@ -514,7 +508,12 @@ function SignInStartInternal(): JSX.Element { return components[identifierField.type as keyof typeof components]; }, [identifierField.type]); - if (status.isLoading || clerkStatus === 'sign_up') { + const { isRedirecting } = useSignInRedirect({ + afterSignInUrl, + organizationTicket, + }); + + if (isRedirecting || status.isLoading || clerkStatus === 'sign_up') { // clerkStatus being sign_up will trigger a navigation to the sign up flow, so show a loading card instead of // rendering the sign in flow. return ; @@ -698,6 +697,4 @@ const InstantPasswordRow = ({ field }: { field?: FormControlState<'password'> }) ); }; -export const SignInStart = withRedirectToSignInTask( - withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)), -); +export const SignInStart = withCardStateProvider(SignInStartInternal); diff --git a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx index dd3d37e9c1f..e49dbddd90d 100644 --- a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx @@ -5,6 +5,7 @@ import { navigateIfTaskExists } from '@/core/sessionTasks'; import { useEnvironment } from '@/ui/contexts'; import { useCardState } from '@/ui/elements/contexts'; import { sleep } from '@/ui/utils/sleep'; +import { buildURL } from '@/utils/url'; import { windowNavigate } from '../../../utils/windowNavigate'; import { useMultipleSessions } from '../../hooks/useMultipleSessions'; @@ -25,7 +26,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { const { setActive, signOut, openUserProfile } = useClerk(); const card = useCardState(); const { signedInSessions, otherSessions } = useMultipleSessions({ user: opts.user }); - const { navigate } = useRouter(); + const { navigate, queryParams } = useRouter(); const { displayConfig } = useEnvironment(); const handleSignOutSessionClicked = (session: SignedInSessionResource) => () => { @@ -99,7 +100,15 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { }; const handleAddAccountClicked = () => { - windowNavigate(opts.signInUrl || window.location.href); + const baseUrl = opts.signInUrl || (typeof window !== 'undefined' ? window.location.href : ''); + const url = buildURL( + { + base: baseUrl, + searchParams: new URLSearchParams({ ...queryParams, __clerk_add_account: 'true' }), + }, + { stringify: true }, + ); + windowNavigate(url); return sleep(2000); }; diff --git a/packages/clerk-js/src/ui/hooks/__tests__/useRedirectEngine.test.tsx b/packages/clerk-js/src/ui/hooks/__tests__/useRedirectEngine.test.tsx new file mode 100644 index 00000000000..b9fd66e993e --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/__tests__/useRedirectEngine.test.tsx @@ -0,0 +1,198 @@ +import type { Clerk, EnvironmentResource } from '@clerk/types'; +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useRedirectEngine } from '../useRedirectEngine'; + +// Mock dependencies +vi.mock('@clerk/shared/react', () => ({ + useClerk: vi.fn(), +})); + +vi.mock('../../contexts', () => ({ + useEnvironment: vi.fn(), +})); + +vi.mock('../../router', () => ({ + useRouter: vi.fn(), +})); + +vi.mock('../../utils/redirectRules', async () => { + const actual = await vi.importActual('../../utils/redirectRules'); + return { + ...actual, + evaluateRedirectRules: vi.fn(), + }; +}); + +import { useClerk } from '@clerk/shared/react'; + +import { useEnvironment } from '../../contexts'; +import { useRouter } from '../../router'; +import { evaluateRedirectRules } from '../../utils/redirectRules'; + +describe('useRedirectEngine', () => { + const mockNavigate = vi.fn(); + const mockClerk = { + isSignedIn: false, + client: { sessions: [], signedInSessions: [] }, + } as unknown as Clerk; + const mockEnvironment = { + authConfig: { singleSessionMode: true }, + } as EnvironmentResource; + + beforeEach(() => { + vi.clearAllMocks(); + (useClerk as any).mockReturnValue(mockClerk); + (useEnvironment as any).mockReturnValue(mockEnvironment); + (useRouter as any).mockReturnValue({ navigate: mockNavigate, currentPath: '/sign-in' }); + (evaluateRedirectRules as any).mockReturnValue(null); + }); + + it('returns isRedirecting false when no redirect needed', () => { + const { result } = renderHook(() => + useRedirectEngine({ + rules: [], + additionalContext: {}, + }), + ); + + expect(result.current.isRedirecting).toBe(false); + }); + + it('navigates when redirect rule matches', async () => { + (evaluateRedirectRules as any).mockReturnValue({ + destination: '/dashboard', + reason: 'Test redirect', + }); + + const { result } = renderHook(() => + useRedirectEngine({ + rules: [], + additionalContext: {}, + }), + ); + + await waitFor(() => { + expect(result.current.isRedirecting).toBe(true); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + }); + }); + + it('does not navigate when skipNavigation is true', async () => { + (evaluateRedirectRules as any).mockReturnValue({ + destination: '/current', + reason: 'Side effect only', + skipNavigation: true, + }); + + renderHook(() => + useRedirectEngine({ + rules: [], + additionalContext: {}, + }), + ); + + await waitFor(() => { + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + it('handles cleanupQueryParams declaratively', async () => { + const mockReplaceState = vi.fn(); + Object.defineProperty(window, 'history', { + value: { replaceState: mockReplaceState }, + writable: true, + }); + Object.defineProperty(window, 'location', { + value: { search: '?__clerk_add_account=true&other=value', pathname: '/sign-in' }, + writable: true, + }); + + (evaluateRedirectRules as any).mockReturnValue({ + destination: '/sign-in', + reason: 'Cleanup params', + skipNavigation: true, + cleanupQueryParams: ['__clerk_add_account'], + }); + + renderHook(() => + useRedirectEngine({ + rules: [], + additionalContext: {}, + }), + ); + + await waitFor(() => { + expect(mockReplaceState).toHaveBeenCalledWith({}, '', '/sign-in?other=value'); + }); + }); + + it('passes additional context to evaluateRedirectRules', async () => { + const additionalContext = { + afterSignInUrl: '/custom', + organizationTicket: 'test_ticket', + }; + + renderHook(() => + useRedirectEngine({ + rules: [], + additionalContext, + }), + ); + + await waitFor(() => { + expect(evaluateRedirectRules).toHaveBeenCalledWith( + [], + expect.objectContaining({ + clerk: mockClerk, + currentPath: '/sign-in', + environment: mockEnvironment, + ...additionalContext, + }), + ); + }); + }); + + it('re-evaluates when auth state changes', async () => { + const { rerender } = renderHook(() => + useRedirectEngine({ + rules: [], + additionalContext: {}, + }), + ); + + expect(evaluateRedirectRules).toHaveBeenCalledTimes(1); + + // Change auth state + (useClerk as any).mockReturnValue({ + ...mockClerk, + isSignedIn: true, + }); + + rerender(); + + await waitFor(() => { + expect(evaluateRedirectRules).toHaveBeenCalledTimes(2); + }); + }); + + it('handles type-safe additional context', () => { + interface CustomContext { + customField: string; + optionalField?: number; + } + + const { result } = renderHook(() => + useRedirectEngine({ + rules: [], + additionalContext: { + customField: 'test', + optionalField: 42, + }, + }), + ); + + expect(result.current.isRedirecting).toBe(false); + }); +}); diff --git a/packages/clerk-js/src/ui/hooks/__tests__/useSignInRedirect.test.tsx b/packages/clerk-js/src/ui/hooks/__tests__/useSignInRedirect.test.tsx new file mode 100644 index 00000000000..a6504f4113a --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/__tests__/useSignInRedirect.test.tsx @@ -0,0 +1,72 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSignInRedirect } from '../useSignInRedirect'; + +vi.mock('../useRedirectEngine', () => ({ + useRedirectEngine: vi.fn(() => ({ isRedirecting: false })), +})); + +vi.mock('../../router', () => ({ + useRouter: vi.fn(() => ({ queryParams: {} })), +})); + +vi.mock('../../utils/redirectRules', () => ({ + signInRedirectRules: [], +})); + +import { useRouter } from '../../router'; +import { useRedirectEngine } from '../useRedirectEngine'; + +describe('useSignInRedirect', () => { + beforeEach(() => { + vi.clearAllMocks(); + (useRouter as any).mockReturnValue({ queryParams: { test: 'value' } }); + }); + + it('calls useRedirectEngine with signInRedirectRules', () => { + renderHook(() => + useSignInRedirect({ + afterSignInUrl: '/dashboard', + organizationTicket: 'test_ticket', + }), + ); + + expect(useRedirectEngine).toHaveBeenCalledWith({ + rules: [], + additionalContext: expect.objectContaining({ + afterSignInUrl: '/dashboard', + organizationTicket: 'test_ticket', + queryParams: { test: 'value' }, + hasInitializedRef: expect.objectContaining({ current: expect.any(Boolean) }), + }), + }); + }); + + it('returns isRedirecting from useRedirectEngine', () => { + (useRedirectEngine as any).mockReturnValue({ isRedirecting: true }); + + const { result } = renderHook(() => + useSignInRedirect({ + afterSignInUrl: '/dashboard', + }), + ); + + expect(result.current.isRedirecting).toBe(true); + }); + + it('sets hasInitializedRef to true after first render', () => { + const { rerender } = renderHook(() => + useSignInRedirect({ + afterSignInUrl: '/dashboard', + }), + ); + + const [[firstCall]] = (useRedirectEngine as any).mock.calls; + const ref = firstCall.additionalContext.hasInitializedRef; + + // After first render, ref should be true + rerender(); + expect(ref.current).toBe(true); + }); +}); diff --git a/packages/clerk-js/src/ui/hooks/__tests__/useSignUpRedirect.test.tsx b/packages/clerk-js/src/ui/hooks/__tests__/useSignUpRedirect.test.tsx new file mode 100644 index 00000000000..d1ad75f4303 --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/__tests__/useSignUpRedirect.test.tsx @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSignUpRedirect } from '../useSignUpRedirect'; + +vi.mock('../useRedirectEngine', () => ({ + useRedirectEngine: vi.fn(() => ({ isRedirecting: false })), +})); + +vi.mock('../../router', () => ({ + useRouter: vi.fn(() => ({ queryParams: {} })), +})); + +vi.mock('../../utils/redirectRules', () => ({ + signUpRedirectRules: [], +})); + +import { useRouter } from '../../router'; +import { useRedirectEngine } from '../useRedirectEngine'; + +describe('useSignUpRedirect', () => { + beforeEach(() => { + vi.clearAllMocks(); + (useRouter as any).mockReturnValue({ queryParams: { test: 'value' } }); + }); + + it('calls useRedirectEngine with signUpRedirectRules', () => { + renderHook(() => + useSignUpRedirect({ + afterSignUpUrl: '/onboarding', + }), + ); + + expect(useRedirectEngine).toHaveBeenCalledWith({ + rules: [], + additionalContext: expect.objectContaining({ + afterSignUpUrl: '/onboarding', + queryParams: { test: 'value' }, + }), + }); + }); + + it('returns isRedirecting from useRedirectEngine', () => { + (useRedirectEngine as any).mockReturnValue({ isRedirecting: true }); + + const { result } = renderHook(() => + useSignUpRedirect({ + afterSignUpUrl: '/onboarding', + }), + ); + + expect(result.current.isRedirecting).toBe(true); + }); + + it('does not include hasInitializedRef for SignUp flow', () => { + renderHook(() => + useSignUpRedirect({ + afterSignUpUrl: '/onboarding', + }), + ); + + const [[call]] = (useRedirectEngine as any).mock.calls; + expect(call.additionalContext.hasInitializedRef).toBeUndefined(); + }); +}); diff --git a/packages/clerk-js/src/ui/hooks/index.ts b/packages/clerk-js/src/ui/hooks/index.ts index b456a3dd943..daba77acc55 100644 --- a/packages/clerk-js/src/ui/hooks/index.ts +++ b/packages/clerk-js/src/ui/hooks/index.ts @@ -16,4 +16,6 @@ export * from './usePrefersReducedMotion'; export * from './useSafeState'; export * from './useScrollLock'; export * from './useSearchInput'; +export * from './useSignInRedirect'; +export * from './useSignUpRedirect'; export * from './useWindowEventListener'; diff --git a/packages/clerk-js/src/ui/hooks/useRedirectEngine.ts b/packages/clerk-js/src/ui/hooks/useRedirectEngine.ts new file mode 100644 index 00000000000..4735ec9cb9c --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/useRedirectEngine.ts @@ -0,0 +1,68 @@ +import { useClerk } from '@clerk/shared/react'; +import React from 'react'; + +import { useEnvironment } from '../contexts'; +import { useRouter } from '../router'; +import type { RedirectContext, RedirectRule } from '../utils/redirectRules'; +import { evaluateRedirectRules } from '../utils/redirectRules'; + +interface UseRedirectEngineOptions = Record> { + additionalContext?: C; + rules: RedirectRule[]; +} + +interface UseRedirectEngineReturn { + isRedirecting: boolean; +} + +/** + * Internal redirect engine - use dedicated hooks like useSignInRedirect instead + * @internal + */ +export function useRedirectEngine = Record>( + options: UseRedirectEngineOptions, +): UseRedirectEngineReturn { + const clerk = useClerk(); + const environment = useEnvironment(); + const { navigate, currentPath } = useRouter(); + const [isRedirecting, setIsRedirecting] = React.useState(false); + + React.useEffect(() => { + const context = { + clerk, + currentPath, + environment, + ...options.additionalContext, + } as RedirectContext & C; + + const result = evaluateRedirectRules(options.rules, context); + + if (result) { + if (result.cleanupQueryParams && typeof window !== 'undefined' && window.history) { + const params = new URLSearchParams(window.location.search); + result.cleanupQueryParams.forEach(param => params.delete(param)); + const newSearch = params.toString(); + const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : ''); + window.history.replaceState({}, '', newUrl); + } + + if (!result.skipNavigation) { + setIsRedirecting(true); + void navigate(result.destination); + } + } else { + setIsRedirecting(false); + } + }, [ + clerk.isSignedIn, + clerk.client?.sessions?.length, + clerk.client?.signedInSessions?.length, + currentPath, + environment.authConfig.singleSessionMode, + navigate, + options.additionalContext, + options.rules, + ]); + + return { isRedirecting }; +} diff --git a/packages/clerk-js/src/ui/hooks/useSignInRedirect.ts b/packages/clerk-js/src/ui/hooks/useSignInRedirect.ts new file mode 100644 index 00000000000..9444fc5509a --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/useSignInRedirect.ts @@ -0,0 +1,38 @@ +import { useEffect, useMemo, useRef } from 'react'; + +import { useRouter } from '../router'; +import { signInRedirectRules } from '../utils/redirectRules'; +import { useRedirectEngine } from './useRedirectEngine'; + +export interface UseSignInRedirectOptions { + afterSignInUrl?: string; + organizationTicket?: string; +} + +export interface UseSignInRedirectReturn { + isRedirecting: boolean; +} + +export function useSignInRedirect(options: UseSignInRedirectOptions): UseSignInRedirectReturn { + const hasInitializedRef = useRef(false); + const { queryParams } = useRouter(); + + useEffect(() => { + hasInitializedRef.current = true; + }, []); + + const additionalContext = useMemo( + () => ({ + afterSignInUrl: options.afterSignInUrl, + hasInitializedRef, + organizationTicket: options.organizationTicket, + queryParams, + }), + [options.afterSignInUrl, options.organizationTicket, queryParams], + ); + + return useRedirectEngine({ + rules: signInRedirectRules, + additionalContext, + }); +} diff --git a/packages/clerk-js/src/ui/hooks/useSignUpRedirect.ts b/packages/clerk-js/src/ui/hooks/useSignUpRedirect.ts new file mode 100644 index 00000000000..13737090523 --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/useSignUpRedirect.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; + +import { useRouter } from '../router'; +import { signUpRedirectRules } from '../utils/redirectRules'; +import { useRedirectEngine } from './useRedirectEngine'; + +export interface UseSignUpRedirectOptions { + afterSignUpUrl?: string; +} + +export interface UseSignUpRedirectReturn { + isRedirecting: boolean; +} + +export function useSignUpRedirect(options: UseSignUpRedirectOptions): UseSignUpRedirectReturn { + const { queryParams } = useRouter(); + + const additionalContext = useMemo( + () => ({ + afterSignUpUrl: options.afterSignUpUrl, + queryParams, + }), + [options.afterSignUpUrl, queryParams], + ); + + return useRedirectEngine({ + rules: signUpRedirectRules, + additionalContext, + }); +} diff --git a/packages/clerk-js/src/ui/utils/__tests__/redirectRules.test.ts b/packages/clerk-js/src/ui/utils/__tests__/redirectRules.test.ts new file mode 100644 index 00000000000..d611803fe74 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/__tests__/redirectRules.test.ts @@ -0,0 +1,318 @@ +import type { Clerk, EnvironmentResource } from '@clerk/types'; +import { describe, expect, it } from 'vitest'; + +import type { RedirectContext } from '../redirectRules'; +import { evaluateRedirectRules, signInRedirectRules } from '../redirectRules'; + +describe('evaluateRedirectRules', () => { + it('returns null when no rules match', () => { + const context: RedirectContext = { + clerk: { isSignedIn: false } as Clerk, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: true }, + } as EnvironmentResource, + }; + + const result = evaluateRedirectRules([], context); + expect(result).toBeNull(); + }); + + it('returns the first matching rule', () => { + const rules = [ + () => [null, false] as const, + () => [{ destination: '/first', reason: 'First rule' }, false] as const, + () => [{ destination: '/second', reason: 'Second rule' }, false] as const, + ]; + + const context: RedirectContext = { + clerk: {} as Clerk, + currentPath: '/sign-in', + environment: {} as EnvironmentResource, + }; + + const result = evaluateRedirectRules(rules, context); + expect(result).toEqual({ destination: '/first', reason: 'First rule' }); + }); + + it('handles shouldStop flag and returns null', () => { + const rules = [ + () => [null, true] as const, + () => [{ destination: '/should-not-reach', reason: 'Should not execute' }, false] as const, + ]; + + const context: RedirectContext = { + clerk: {} as Clerk, + currentPath: '/sign-in', + environment: {} as EnvironmentResource, + }; + + const result = evaluateRedirectRules(rules, context); + expect(result).toBeNull(); + }); +}); + +describe('signInRedirectRules', () => { + describe('organization ticket guard', () => { + it('stops evaluation when organization ticket is present', () => { + const context: RedirectContext = { + clerk: { + buildAfterSignInUrl: () => '/dashboard', + isSignedIn: true, + } as Clerk, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: true }, + } as EnvironmentResource, + organizationTicket: 'test_ticket', + afterSignInUrl: '/custom', + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + // Should return null because guard stops evaluation + expect(result).toBeNull(); + }); + + it('continues evaluation when no organization ticket', () => { + const context: RedirectContext = { + clerk: { + buildAfterSignInUrl: () => '/dashboard', + isSignedIn: true, + } as Clerk, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: true }, + } as EnvironmentResource, + afterSignInUrl: '/custom', + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + // Should match single session rule + expect(result).toEqual({ + destination: '/custom', + reason: 'User already signed in (single session mode)', + }); + }); + }); + + describe('single session mode redirect', () => { + it('redirects to afterSignInUrl when already signed in', () => { + const context: RedirectContext = { + clerk: { + buildAfterSignInUrl: () => '/default', + isSignedIn: true, + } as Clerk, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: true }, + } as EnvironmentResource, + afterSignInUrl: '/dashboard', + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + + expect(result).toEqual({ + destination: '/dashboard', + reason: 'User already signed in (single session mode)', + }); + }); + + it('uses clerk.buildAfterSignInUrl when afterSignInUrl not provided', () => { + const context: RedirectContext = { + clerk: { + buildAfterSignInUrl: () => '/default', + isSignedIn: true, + } as Clerk, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: true }, + } as EnvironmentResource, + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + + expect(result).toEqual({ + destination: '/default', + reason: 'User already signed in (single session mode)', + }); + }); + + it('does not redirect when not signed in', () => { + const context: RedirectContext = { + clerk: { + buildAfterSignInUrl: () => '/default', + isSignedIn: false, + } as Clerk, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: true }, + } as EnvironmentResource, + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + expect(result).toBeNull(); + }); + + it('does not redirect in multi-session mode', () => { + const context: RedirectContext = { + clerk: { + buildAfterSignInUrl: () => '/default', + isSignedIn: true, + client: { signedInSessions: [] }, + } as any, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: false }, + } as EnvironmentResource, + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + // Should not match single session rule, should evaluate other rules + expect(result).not.toEqual(expect.objectContaining({ reason: 'User already signed in (single session mode)' })); + }); + }); + + describe('multi-session mode account switcher redirect', () => { + it('redirects to /sign-in/choose at root with active sessions', () => { + const context: RedirectContext = { + clerk: { + client: { sessions: [{ id: '1' }, { id: '2' }], signedInSessions: [{ id: '1' }, { id: '2' }] }, + isSignedIn: true, + } as any, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: false }, + } as EnvironmentResource, + hasInitializedRef: { current: false }, + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + + expect(result).toEqual({ + destination: 'choose', + reason: 'Active sessions detected (multi-session mode)', + }); + }); + + it('redirects to /sign-in/choose at root with trailing slash', () => { + const context: RedirectContext = { + clerk: { + client: { sessions: [{ id: '1' }], signedInSessions: [{ id: '1' }] }, + isSignedIn: true, + } as any, + currentPath: '/sign-in/', + environment: { + authConfig: { singleSessionMode: false }, + } as EnvironmentResource, + hasInitializedRef: { current: false }, + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + + expect(result).toEqual({ + destination: 'choose', + reason: 'Active sessions detected (multi-session mode)', + }); + }); + + it('does not redirect when already initialized', () => { + const context: RedirectContext = { + clerk: { + client: { sessions: [{ id: '1' }], signedInSessions: [{ id: '1' }] }, + isSignedIn: true, + } as any, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: false }, + } as EnvironmentResource, + hasInitializedRef: { current: true }, + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + expect(result).toBeNull(); + }); + + it('does not redirect when no active sessions', () => { + const context: RedirectContext = { + clerk: { + client: { sessions: [] }, + isSignedIn: false, + } as any, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: false }, + } as EnvironmentResource, + hasInitializedRef: { current: false }, + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + expect(result).toBeNull(); + }); + }); + + describe('rule priority', () => { + it('single session mode takes precedence over multi-session when both conditions met', () => { + const context: RedirectContext = { + clerk: { + buildAfterSignInUrl: () => '/dashboard', + client: { sessions: [{ id: '1' }], signedInSessions: [{ id: '1' }] }, + isSignedIn: true, + } as any, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: true }, + } as EnvironmentResource, + hasInitializedRef: { current: false }, + afterSignInUrl: '/custom', + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + // Should match first rule instead (single session redirect) + expect(result?.reason).toBe('User already signed in (single session mode)'); + }); + }); + + describe('add account flow', () => { + it('returns skip navigation when __clerk_add_account query param is present', () => { + const context: RedirectContext = { + clerk: { + client: { sessions: [], signedInSessions: [] }, + isSignedIn: false, + } as any, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: false }, + } as EnvironmentResource, + queryParams: { + __clerk_add_account: 'true', + }, + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + expect(result).toMatchObject({ + destination: '/sign-in', + reason: 'User is adding account', + skipNavigation: true, + cleanupQueryParams: ['__clerk_add_account'], + }); + }); + + it('does not skip navigation when __clerk_add_account query param is absent', () => { + const context: RedirectContext = { + clerk: { + client: { sessions: [], signedInSessions: [] }, + isSignedIn: false, + } as any, + currentPath: '/sign-in', + environment: { + authConfig: { singleSessionMode: false }, + } as EnvironmentResource, + queryParams: {}, + }; + + const result = evaluateRedirectRules(signInRedirectRules, context); + // Should evaluate other rules + expect(result?.reason).not.toBe('User is adding account'); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/redirectRules.ts b/packages/clerk-js/src/ui/utils/redirectRules.ts new file mode 100644 index 00000000000..a1e0b3251c6 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/redirectRules.ts @@ -0,0 +1,168 @@ +import type { Clerk, EnvironmentResource } from '@clerk/types'; +import type React from 'react'; + +import { debugLogger } from '../../utils/debug'; + +export interface RedirectContext { + readonly afterSignInUrl?: string; + readonly afterSignUpUrl?: string; + readonly clerk: Clerk; + readonly currentPath: string; + readonly environment: EnvironmentResource; + readonly hasInitializedRef?: React.MutableRefObject; + readonly organizationTicket?: string; + readonly queryParams?: Record; +} + +export interface RedirectResult { + readonly destination: string; + readonly reason: string; + readonly skipNavigation?: boolean; + readonly cleanupQueryParams?: string[]; +} + +export type RedirectRule = Record> = ( + context: RedirectContext & T, +) => readonly [RedirectResult | null, boolean]; + +function isValidRedirectUrl(url: string): boolean { + try { + if (url.startsWith('/')) { + return true; + } + const parsed = new URL(url, window.location.origin); + return parsed.origin === window.location.origin; + } catch { + return false; + } +} + +/** + * Evaluates redirect rules in order, returning the first match. + * + * @param rules - Array of redirect rules to evaluate + * @param context - Context containing clerk instance, path, environment, etc. + * @returns The first matching redirect result, or null if no rules match + */ +export function evaluateRedirectRules = Record>( + rules: RedirectRule[], + context: RedirectContext & T, +): RedirectResult | null { + for (const rule of rules) { + const [result, shouldStop] = rule(context); + + if (shouldStop) { + debugLogger.info('Redirect evaluation stopped', { reason: 'Guard triggered' }, 'redirect'); + return null; + } + + if (result) { + debugLogger.info( + 'Redirect rule matched', + { + destination: result.destination, + reason: result.reason, + }, + 'redirect', + ); + return result; + } + } + return null; +} + +/** + * Redirect rules for SignIn component + */ +export const signInRedirectRules: RedirectRule[] = [ + // Guard: Organization ticket flow is handled separately in component + ctx => { + if (ctx.organizationTicket) { + return [null, true]; + } + return [null, false]; + }, + + // Rule 1: Single session mode - user already signed in + ctx => { + if (ctx.clerk.isSignedIn && ctx.environment.authConfig.singleSessionMode) { + let destination = ctx.afterSignInUrl || ctx.clerk.buildAfterSignInUrl(); + + if (ctx.afterSignInUrl && !isValidRedirectUrl(ctx.afterSignInUrl)) { + destination = ctx.clerk.buildAfterSignInUrl(); + } + + return [ + { + destination, + reason: 'User already signed in (single session mode)', + }, + false, + ]; + } + return [null, false]; + }, + + // Rule 2: Skip redirect if adding account (preserves add account flow) + ctx => { + const isAddingAccount = ctx.queryParams?.['__clerk_add_account']; + if (isAddingAccount) { + return [ + { + destination: ctx.currentPath, + reason: 'User is adding account', + skipNavigation: true, + cleanupQueryParams: ['__clerk_add_account'], + }, + false, + ]; + } + return [null, false]; + }, + + // Rule 3: Multi-session mode - redirect to account switcher with active sessions + ctx => { + if (ctx.hasInitializedRef?.current) { + return [null, false]; + } + + const isMultiSessionMode = !ctx.environment.authConfig.singleSessionMode; + const hasActiveSessions = (ctx.clerk.client?.signedInSessions?.length ?? 0) > 0; + + if (hasActiveSessions && isMultiSessionMode) { + return [ + { + destination: 'choose', + reason: 'Active sessions detected (multi-session mode)', + }, + false, + ]; + } + return [null, false]; + }, +]; + +/** + * Redirect rules for SignUp component + */ +export const signUpRedirectRules: RedirectRule[] = [ + // Rule 1: Single session mode - user already signed in + ctx => { + if (ctx.clerk.isSignedIn && ctx.environment.authConfig.singleSessionMode) { + let destination = ctx.afterSignUpUrl || ctx.clerk.buildAfterSignUpUrl(); + + if (ctx.afterSignUpUrl && !isValidRedirectUrl(ctx.afterSignUpUrl)) { + destination = ctx.clerk.buildAfterSignUpUrl(); + } + + return [ + { + destination, + reason: 'User already signed in (single session mode)', + }, + false, + ]; + } + return [null, false]; + }, +];