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()
+ })
+})