diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6433835..9349aac 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import FreighterBanner from './components/FreighterBanner'; import DemoBanner from './components/DemoBanner'; import NavBar from './components/NavBar'; import SkipToContent from './components/SkipToContent'; +import RequireAuth from './components/RequireAuth'; export default function App() { const [dark, setDark] = useDarkMode(); @@ -21,14 +22,12 @@ export default function App() { setDark((d) => !d)} /> - - setDark(d => !d)} />
} /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/RequireAuth.jsx b/frontend/src/components/RequireAuth.jsx new file mode 100644 index 0000000..b30d6d6 --- /dev/null +++ b/frontend/src/components/RequireAuth.jsx @@ -0,0 +1,20 @@ +import { useLocation, Navigate } from 'react-router-dom'; +import { useAuth } from '../hooks/useFreighter'; + +/** + * Route guard component. + * + * @param {string} [requiredRole] - If set, user must also have this role. + */ +export default function RequireAuth({ children, requiredRole }) { + const { isConnected, role, hydrating } = useAuth(); + const location = useLocation(); + + if (hydrating) return null; + + if (!isConnected || (requiredRole && role !== requiredRole)) { + return ; + } + + return children; +} diff --git a/frontend/src/components/RequireAuth.test.jsx b/frontend/src/components/RequireAuth.test.jsx new file mode 100644 index 0000000..3132af6 --- /dev/null +++ b/frontend/src/components/RequireAuth.test.jsx @@ -0,0 +1,131 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import RequireAuth from './RequireAuth'; + +jest.mock('../hooks/useFreighter', () => ({ useAuth: jest.fn() })); +import { useAuth } from '../hooks/useFreighter'; + +const Protected = () =>
protected
; +const Landing = () =>
landing
; + +function renderWithRouter(initialPath, authValue) { + useAuth.mockReturnValue(authValue); + return render( + + + } /> + } + /> + } + /> + + + ); +} + +describe('RequireAuth', () => { + describe('while hydrating', () => { + it('renders nothing (no flash of protected content)', () => { + const { container } = renderWithRouter('/patient', { + isConnected: false, role: null, hydrating: true, + }); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('/patient — unauthenticated', () => { + it('redirects to / when not connected', () => { + renderWithRouter('/patient', { isConnected: false, role: null, hydrating: false }); + expect(screen.getByText('landing')).toBeInTheDocument(); + expect(screen.queryByText('protected')).not.toBeInTheDocument(); + }); + }); + + describe('/patient — authenticated (any role)', () => { + it('renders the protected page for a patient role', () => { + renderWithRouter('/patient', { isConnected: true, role: 'patient', hydrating: false }); + expect(screen.getByText('protected')).toBeInTheDocument(); + }); + + it('renders the protected page for an issuer role', () => { + renderWithRouter('/patient', { isConnected: true, role: 'issuer', hydrating: false }); + expect(screen.getByText('protected')).toBeInTheDocument(); + }); + }); + + describe('/issuer — no issuer role', () => { + it('redirects to / when not connected', () => { + renderWithRouter('/issuer', { isConnected: false, role: null, hydrating: false }); + expect(screen.getByText('landing')).toBeInTheDocument(); + }); + + it('redirects to / when connected as patient', () => { + renderWithRouter('/issuer', { isConnected: true, role: 'patient', hydrating: false }); + expect(screen.getByText('landing')).toBeInTheDocument(); + expect(screen.queryByText('protected')).not.toBeInTheDocument(); + }); + }); + + describe('/issuer — with issuer role', () => { + it('renders the protected page', () => { + renderWithRouter('/issuer', { isConnected: true, role: 'issuer', hydrating: false }); + expect(screen.getByText('protected')).toBeInTheDocument(); + }); + }); + + describe('redirect preserves intended destination', () => { + it('passes state.from when redirecting from /patient', () => { + let capturedState; + const LandingCapture = () => { + // Access location via useLocation inside a component + const { useLocation } = require('react-router-dom'); + capturedState = useLocation().state; + return
landing
; + }; + useAuth.mockReturnValue({ isConnected: false, role: null, hydrating: false }); + render( + + + } /> + } /> + + + ); + expect(capturedState).toEqual({ from: '/patient' }); + }); + }); + + describe('re-evaluation on auth change', () => { + it('redirects when isConnected changes to false after render', () => { + // Initial render: connected + const authValue = { isConnected: true, role: 'patient', hydrating: false }; + useAuth.mockReturnValue(authValue); + const { rerender } = render( + + + } /> + } /> + + + ); + expect(screen.getByText('protected')).toBeInTheDocument(); + + // Simulate JWT expiry: isConnected becomes false + useAuth.mockReturnValue({ isConnected: false, role: null, hydrating: false }); + rerender( + + + } /> + } /> + + + ); + expect(screen.getByText('landing')).toBeInTheDocument(); + expect(screen.queryByText('protected')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/hooks/useFreighter.jsx b/frontend/src/hooks/useFreighter.jsx index 19bf8b9..004b0b0 100644 --- a/frontend/src/hooks/useFreighter.jsx +++ b/frontend/src/hooks/useFreighter.jsx @@ -25,6 +25,7 @@ export function AuthProvider({ children }) { const [role, setRole] = useState(null); const [loading, setLoading] = useState(false); const [connectionStep, setConnectionStep] = useState(CONNECTION_STEPS.IDLE); + const [hydrating, setHydrating] = useState(() => !!localStorage.getItem(STORAGE_KEY)); const [error, setError] = useState(null); const [freighterInstalled, setFreighterInstalled] = useState(() => typeof window !== 'undefined' && !!window.freighter); // Token lives only in memory — never written to localStorage @@ -100,12 +101,13 @@ export function AuthProvider({ children }) { if (!connected) { setFreighterInstalled(false); localStorage.removeItem(STORAGE_KEY); - return; + } else { + // Restore identity so UI shows as connected; token will be fetched on first apiFetch call + setPublicKey(savedKey); + setRole(savedRole); } - // Restore identity so UI shows as connected; token will be fetched on first apiFetch call - setPublicKey(savedKey); - setRole(savedRole); - }).catch(() => localStorage.removeItem(STORAGE_KEY)); + }).catch(() => localStorage.removeItem(STORAGE_KEY)) + .finally(() => setHydrating(false)); }, []); const apiFetch = useCallback(async (url, options = {}) => { @@ -148,6 +150,7 @@ export function AuthProvider({ children }) { isConnected: isConnectedState, loading, connectionStep, + hydrating, error, }}> {children}