Skip to content
Open
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
28 changes: 18 additions & 10 deletions src/features/auth/pages/AuthCallbackPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -146,19 +142,31 @@ export function AuthCallbackPage() {
Authentication Failed
</h2>
<p
className={`text-sm transition-colors ${
className={`text-sm mb-6 transition-colors ${
theme === 'dark' ? 'text-[#d4d4d4]' : 'text-[#7a6b5a]'
}`}
>
{error}
</p>
<p
className={`text-xs mt-4 transition-colors ${
theme === 'dark' ? 'text-[#a3a3a3]' : 'text-[#8a7d6f]'
<button
type="button"
onClick={() => 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...
</p>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Try signing in again
</button>
</div>
) : isProcessing ? (
<div className="text-center">
Expand Down
95 changes: 91 additions & 4 deletions src/features/auth/pages/SignInPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -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')

Expand All @@ -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')

Expand All @@ -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(
<MemoryRouter initialEntries={['/auth/callback?token=bad']}>
<Routes>
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/signin" element={<div data-testid="signin-page">Sign In</div>} />
<Route path="*" element={<LocationProbe />} />
</Routes>
</MemoryRouter>
)

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