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