diff --git a/README.md b/README.md
index 233cc4c..fd63b38 100644
--- a/README.md
+++ b/README.md
@@ -192,3 +192,8 @@ Training labels: `data/disruption_labels.csv` — 50+ public disruption case stu
**Phase A (real data):** TimescaleDB score history, link confidence, batch rescore — see [docs/REAL_DATA_PHASE_A.md](docs/REAL_DATA_PHASE_A.md).
---
+
+### Frontend auth guard
+
+Protected dashboard routes use a `RequireAuth` wrapper (`frontend/src/components/RequireAuth.jsx`) that redirects to `/login` when no JWT is in `localStorage`.
+
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 4ddda6c..c9ce7b2 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Layout } from './components/Layout';
+import { RequireAuth } from './components/RequireAuth';
import { EntityDrawerProvider } from './context/EntityDrawerContext';
import { Dashboard } from './pages/Dashboard';
import { NetworkView } from './pages/NetworkView';
@@ -24,6 +25,10 @@ const queryClient = new QueryClient({
},
});
+function Protected({ children }) {
+ return {children};
+}
+
function App() {
return (
@@ -31,16 +36,16 @@ function App() {
}>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
} />
diff --git a/frontend/src/components/RequireAuth.jsx b/frontend/src/components/RequireAuth.jsx
new file mode 100644
index 0000000..69c5e5d
--- /dev/null
+++ b/frontend/src/components/RequireAuth.jsx
@@ -0,0 +1,18 @@
+import { Navigate, useLocation } from 'react-router-dom';
+
+/**
+ * Route guard: redirect unauthenticated users to /login.
+ * JWT is stored in localStorage by api/client.login().
+ */
+export function RequireAuth({ children }) {
+ const location = useLocation();
+ const token = localStorage.getItem('meridian_access_token');
+ if (!token) {
+ return ;
+ }
+ return children;
+}
+
+export function isAuthenticated() {
+ return Boolean(localStorage.getItem('meridian_access_token'));
+}
diff --git a/frontend/src/components/RequireAuth.test.jsx b/frontend/src/components/RequireAuth.test.jsx
new file mode 100644
index 0000000..79eecfc
--- /dev/null
+++ b/frontend/src/components/RequireAuth.test.jsx
@@ -0,0 +1,48 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
+import { render, screen } from '@testing-library/react';
+import { RequireAuth } from './RequireAuth';
+
+const store = new Map();
+const localStorageMock = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => store.set(k, String(v)),
+ removeItem: (k) => store.delete(k),
+ clear: () => store.clear(),
+};
+vi.stubGlobal('localStorage', localStorageMock);
+
+function renderWithAuth(initialPath = '/alerts') {
+ return render(
+
+
+
+ Secret alerts
+
+ }
+ />
+ Login page} />
+
+
+ );
+}
+
+describe('RequireAuth', () => {
+ beforeEach(() => {
+ store.clear();
+ });
+
+ it('redirects to login when token missing', () => {
+ renderWithAuth();
+ expect(screen.getByText('Login page')).toBeTruthy();
+ });
+
+ it('renders children when token present', () => {
+ localStorage.setItem('meridian_access_token', 'test-jwt');
+ renderWithAuth();
+ expect(screen.getByText('Secret alerts')).toBeTruthy();
+ });
+});