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}