diff --git a/src/features/auth/pages/AuthCallbackPage.tsx b/src/features/auth/pages/AuthCallbackPage.tsx index 1797d616..b2876bf0 100644 --- a/src/features/auth/pages/AuthCallbackPage.tsx +++ b/src/features/auth/pages/AuthCallbackPage.tsx @@ -76,8 +76,6 @@ export function AuthCallbackPage() { setError(errorParam || 'An unexpected error occurred') } setIsProcessing(false) - // Redirect to signin after 3 seconds - setTimeout(() => navigate('/signin', { replace: true }), 3000) return } @@ -86,7 +84,6 @@ export function AuthCallbackPage() { sessionStorage.removeItem(AUTH_RETURN_TO_KEY) setError('No authentication token received') setIsProcessing(false) - setTimeout(() => navigate('/signin', { replace: true }), 3000) return } @@ -101,7 +98,6 @@ export function AuthCallbackPage() { sessionStorage.removeItem(AUTH_RETURN_TO_KEY) setError(err instanceof Error ? err.message : 'Authentication failed') setIsProcessing(false) - setTimeout(() => navigate('/signin', { replace: true }), 3000) } } @@ -146,19 +142,31 @@ export function AuthCallbackPage() { Authentication Failed

{error}

-

navigate('/signin', { replace: true })} + className={`inline-flex items-center gap-2 px-6 py-2.5 rounded-xl font-medium text-sm transition-colors ${ + theme === 'dark' + ? 'bg-[#c9983a] text-[#1a1512] hover:bg-[#d4af37]' + : 'bg-[#c9983a] text-white hover:bg-[#b8892a]' }`} > - Redirecting to sign in... -

+ + + + Try signing in again + ) : isProcessing ? (
diff --git a/src/features/auth/pages/SignInPage.test.tsx b/src/features/auth/pages/SignInPage.test.tsx index 798489ff..57d1d03c 100644 --- a/src/features/auth/pages/SignInPage.test.tsx +++ b/src/features/auth/pages/SignInPage.test.tsx @@ -498,9 +498,9 @@ describe('AuthCallbackPage OAuth token history exposure', () => { expect(screen.getByText('bad token')).toBeInTheDocument() expect(window.location.href).not.toContain('leaked-jwt') - // Fire the delayed redirect; it replaces the callback entry instead of pushing. + // Click the retry button; it replaces the callback entry instead of pushing. await act(async () => { - vi.advanceTimersByTime(3000) + fireEvent.click(screen.getByRole('button', { name: /try signing in again/i })) }) expect(screen.getByTestId('location')).toHaveTextContent('/signin') @@ -523,8 +523,9 @@ describe('AuthCallbackPage OAuth token history exposure', () => { renderCallbackWithHistory(['/signin', '/auth/callback?error=access_denied'], 1) await flushCallback() + expect(screen.getByRole('button', { name: /try signing in again/i })).toBeInTheDocument() await act(async () => { - vi.advanceTimersByTime(3000) + fireEvent.click(screen.getByRole('button', { name: /try signing in again/i })) }) expect(screen.getByTestId('location')).toHaveTextContent('/signin') @@ -544,8 +545,9 @@ describe('AuthCallbackPage OAuth token history exposure', () => { renderCallbackWithHistory(['/signin', '/auth/callback'], 1) await flushCallback() + expect(screen.getByRole('button', { name: /try signing in again/i })).toBeInTheDocument() await act(async () => { - vi.advanceTimersByTime(3000) + fireEvent.click(screen.getByRole('button', { name: /try signing in again/i })) }) expect(screen.getByTestId('location')).toHaveTextContent('/signin') @@ -555,3 +557,88 @@ describe('AuthCallbackPage OAuth token history exposure', () => { expect(screen.getByTestId('location')).toHaveTextContent('/signin') }) }) + +describe('AuthCallbackPage retry button', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + afterEach(() => { + vi.restoreAllMocks() + sessionStorage.clear() + }) + + it('renders a retry button when OAuth returns an error param', async () => { + mockUseAuth.mockReturnValue({ + login: vi.fn().mockResolvedValue(undefined), + isAuthenticated: false, + }) + + renderCallback('/auth/callback?error=access_denied') + + expect(await screen.findByText('Login was cancelled. Please try again.')).toBeInTheDocument() + const retryBtn = screen.getByRole('button', { name: /try signing in again/i }) + expect(retryBtn).toBeInTheDocument() + }) + + it('renders a retry button when login fails with a network error', async () => { + mockUseAuth.mockReturnValue({ + login: vi.fn().mockRejectedValue(new Error('Network error')), + isAuthenticated: false, + }) + + renderCallback('/auth/callback?token=network-fail') + + expect(await screen.findByText('Network error')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /try signing in again/i })).toBeInTheDocument() + }) + + it('renders a retry button when no token is present', async () => { + mockUseAuth.mockReturnValue({ + login: vi.fn().mockResolvedValue(undefined), + isAuthenticated: false, + }) + + renderCallback('/auth/callback') + + expect(await screen.findByText('No authentication token received')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /try signing in again/i })).toBeInTheDocument() + }) + + it('navigates to /signin when the retry button is clicked', async () => { + mockUseAuth.mockReturnValue({ + login: vi.fn().mockRejectedValue(new Error('bad token')), + isAuthenticated: false, + }) + + window.history.pushState({}, '', '/auth/callback?token=bad') + render( + + + } /> + Sign In
} /> + } /> + + + ) + + expect(await screen.findByText('bad token')).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /try signing in again/i })) + }) + expect(screen.getByTestId('signin-page')).toBeInTheDocument() + }) + + it('renders the retry button in dark theme', async () => { + mockUseTheme.mockReturnValue({ theme: 'dark' }) + mockUseAuth.mockReturnValue({ + login: vi.fn().mockRejectedValue(new Error('dark failure')), + isAuthenticated: false, + }) + + renderCallback('/auth/callback?token=dark-jwt') + + expect(await screen.findByText('Authentication Failed')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /try signing in again/i })).toBeInTheDocument() + }) +})