diff --git a/web/mock-server/server.js b/web/mock-server/server.js
index f1c6b18ed..7d9e29e39 100644
--- a/web/mock-server/server.js
+++ b/web/mock-server/server.js
@@ -159,6 +159,15 @@ app.use('/docs', swaggerUi.serve, swaggerUi.setup(mockApiSpec));
// API Routes
app.get('/api/users/debug/locals', (req, res) => {
+ const cookieHeader = req.headers.cookie ?? '';
+ const tokenMatch = cookieHeader.match(/(?:^|;\s*)token=([^;]+)/);
+ const tokenValue = tokenMatch ? tokenMatch[1] : null;
+
+ if (tokenValue === 'stale-jwt-token') {
+ res.status(401).json({ error: 'Unauthorized' });
+ return;
+ }
+
res.json({
locals: {
owner: 1,
diff --git a/web/src/lib/backend/api.ts b/web/src/lib/backend/api.ts
index 4af4ba710..9f709ec17 100644
--- a/web/src/lib/backend/api.ts
+++ b/web/src/lib/backend/api.ts
@@ -85,7 +85,7 @@ export const get = async (
if (!response.ok) {
if (response.status === UNAUTHORIZED) {
redirectToLogin();
- return undefined;
+ throw new Error('Unauthorized');
}
if (response.status === NOT_FOUND) {
throw new Error(
diff --git a/web/src/pages/LoginPage/LoginPage.test.tsx b/web/src/pages/LoginPage/LoginPage.test.tsx
new file mode 100644
index 000000000..dd8905912
--- /dev/null
+++ b/web/src/pages/LoginPage/LoginPage.test.tsx
@@ -0,0 +1,108 @@
+import { render, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { MemoryRouter } from 'react-router-dom';
+import { CookiesProvider } from 'react-cookie';
+import Cookies from 'universal-cookie';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { LoginPage } from './LoginPage';
+
+vi.mock('../../lib/hooks/useUserLocals', () => ({
+ useUserLocals: vi.fn(),
+}));
+
+vi.mock('./components/LoginForm', () => ({
+ default: () =>
,
+}));
+
+vi.mock('../../components/AuthPageBackground', () => ({
+ AuthPageBackground: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+import { useUserLocals } from '../../lib/hooks/useUserLocals';
+
+const mockUseUserLocals = useUserLocals as ReturnType;
+
+function renderLoginPage(cookieValue?: string) {
+ const cookies = new Cookies();
+ if (cookieValue != null) {
+ cookies.set('token', cookieValue, { path: '/' });
+ }
+
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ return render(
+
+
+
+
+
+
+
+ );
+}
+
+describe('LoginPage', () => {
+ beforeEach(() => {
+ const cookies = new Cookies();
+ cookies.remove('token', { path: '/' });
+ vi.clearAllMocks();
+ });
+
+ it('renders the login form when there is no cookie', () => {
+ mockUseUserLocals.mockReturnValue({ data: undefined, isLoading: false, isError: false, error: null });
+ const { getByTestId } = renderLoginPage();
+ expect(getByTestId('login-form')).toBeInTheDocument();
+ });
+
+ it('renders the login form when the cookie is valid', () => {
+ mockUseUserLocals.mockReturnValue({
+ data: { user: { id: 1 }, locals: {} },
+ isLoading: false,
+ isError: false,
+ error: null,
+ });
+ const { getByTestId } = renderLoginPage('valid-token');
+ expect(getByTestId('login-form')).toBeInTheDocument();
+ });
+
+ it('clears the token cookie when the cookie is present and userLocals fetch errors', async () => {
+ mockUseUserLocals.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: new Error('Unauthorized'),
+ });
+
+ const cookies = new Cookies();
+ cookies.set('token', 'stale-jwt-token', { path: '/' });
+
+ renderLoginPage('stale-jwt-token');
+
+ await waitFor(() => {
+ const tokenCookie = cookies.get('token');
+ expect(tokenCookie).toBeUndefined();
+ });
+ });
+
+ it('does not clear the token cookie when no cookie is present and fetch errors', async () => {
+ mockUseUserLocals.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: new Error('Unauthorized'),
+ });
+
+ const cookies = new Cookies();
+
+ renderLoginPage();
+
+ await waitFor(() => {
+ expect(cookies.get('token')).toBeUndefined();
+ });
+ });
+});
diff --git a/web/src/pages/LoginPage/LoginPage.tsx b/web/src/pages/LoginPage/LoginPage.tsx
index 62ea873c5..121c57e54 100644
--- a/web/src/pages/LoginPage/LoginPage.tsx
+++ b/web/src/pages/LoginPage/LoginPage.tsx
@@ -1,7 +1,19 @@
+import { useEffect } from 'react';
+import { useCookies } from 'react-cookie';
import LoginForm from './components/LoginForm';
import { AuthPageBackground } from '../../components/AuthPageBackground';
+import { useUserLocals } from '../../lib/hooks/useUserLocals';
export function LoginPage() {
+ const [cookies, , removeCookie] = useCookies(['token']);
+ const { isError } = useUserLocals();
+
+ useEffect(() => {
+ if (cookies.token && isError) {
+ removeCookie('token', { path: '/' });
+ }
+ }, [cookies.token, isError, removeCookie]);
+
return (
diff --git a/web/tests/login-loop.spec.ts b/web/tests/login-loop.spec.ts
new file mode 100644
index 000000000..b407a8803
--- /dev/null
+++ b/web/tests/login-loop.spec.ts
@@ -0,0 +1,36 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('login-loop prevention', () => {
+ test('clears a stale token cookie and renders the login form without looping', async ({
+ page,
+ context,
+ }) => {
+ await context.addCookies([
+ {
+ name: 'token',
+ value: 'stale-jwt-token',
+ domain: 'localhost',
+ path: '/',
+ httpOnly: false,
+ secure: false,
+ sameSite: 'Lax',
+ },
+ ]);
+
+ await page.goto('/login');
+
+ await expect(page.locator('h1')).toContainText('Log in', {
+ timeout: 10_000,
+ });
+
+ await expect
+ .poll(
+ async () => {
+ const cookies = await context.cookies();
+ return cookies.find((c) => c.name === 'token');
+ },
+ { timeout: 15_000, intervals: [500] }
+ )
+ .toBeUndefined();
+ });
+});