M2-T06: scaffold React SPA frontend with typed OpenAPI client
- Vite + React 18 + TypeScript + Mantine + TanStack Query + react-router-dom - typed client: openapi-typescript -> src/api/schema.d.ts (committed), openapi-fetch - fetch wrapper middleware: cookies, X-CSRF-Token on writes, 401 -> /login, non-401 errors carry parsed JSON body - SessionProvider/useSession (GET /api/session), ProtectedRoute skeleton - app shell (Mantine + router) with placeholder login/home/config pages + gear nav - dev proxy to FastAPI; vitest smoke test; frontend README - npm scripts: dev/build/preview/lint/typecheck/test/codegen
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* App — top-level provider stack and route tree.
|
||||
*
|
||||
* Provider order (outermost first):
|
||||
* MantineProvider → QueryClientProvider → BrowserRouter → SessionProvider → routes
|
||||
*
|
||||
* Route tree:
|
||||
* /login → LoginPage (public, T07 will build the real form)
|
||||
* / → ProtectedRoute → AppLayout → HomePage (T09)
|
||||
* /config → ProtectedRoute → AppLayout → ConfigPage (T08)
|
||||
*
|
||||
* AppLayout renders a minimal shell with a gear-icon nav entry for /config (§5#10).
|
||||
* T07–T10 slot their real pages in without touching the provider/router setup.
|
||||
*/
|
||||
|
||||
import { MantineProvider } from '@mantine/core'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter, Routes, Route, Link, Outlet } from 'react-router-dom'
|
||||
|
||||
// Mantine requires its CSS to be imported once.
|
||||
import '@mantine/core/styles.css'
|
||||
|
||||
import { SessionProvider } from './auth/SessionProvider'
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { ConfigPage } from './pages/ConfigPage'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TanStack Query client (singleton, created outside render to avoid re-creation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Don't retry on 4xx — we handle 401 in the middleware
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof Error && 'status' in error) {
|
||||
const status = (error as unknown as { status: number }).status
|
||||
if (status >= 400 && status < 500) return false
|
||||
}
|
||||
return failureCount < 2
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App shell layout (used by all protected pages)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AppLayout() {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Minimal top nav — T07–T10 can enhance this with Mantine AppShell */}
|
||||
<nav
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.5rem 1rem',
|
||||
borderBottom: '1px solid #eee',
|
||||
}}
|
||||
>
|
||||
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
|
||||
Home Automation
|
||||
</Link>
|
||||
{/* Gear icon nav slot — links to config page (§5#10) */}
|
||||
<Link
|
||||
to="/config"
|
||||
aria-label="Configuration"
|
||||
style={{ fontSize: '1.25rem', textDecoration: 'none' }}
|
||||
title="Configuration"
|
||||
>
|
||||
⚙
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Page content */}
|
||||
<main style={{ flex: 1 }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root app
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<SessionProvider>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected routes — all nested under AppLayout */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</SessionProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Typed API client built on openapi-fetch + generated schema.d.ts.
|
||||
*
|
||||
* Middleware contract (orchestrator-decisions.md §11):
|
||||
* 1. Always send cookies (credentials: "include"; same-origin auto-sends but explicit is clear).
|
||||
* 2. Non-GET/HEAD requests inject X-CSRF-Token from the csrf holder.
|
||||
* Exception: POST /api/auth/login skips injection (unauthenticated endpoint).
|
||||
* 3. 401 responses → clear session state + navigate to /login.
|
||||
* 4. Other non-2xx responses → throw an ApiError carrying the parsed JSON body,
|
||||
* so callers (e.g. SMTP test) can inspect body.result.
|
||||
*/
|
||||
|
||||
import createClient, { type Middleware } from 'openapi-fetch'
|
||||
import type { paths } from './schema.d.ts'
|
||||
import { getCsrfToken } from './csrf'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Error thrown for non-2xx, non-401 responses. Carries the parsed JSON body. */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public readonly body: any,
|
||||
) {
|
||||
super(`API error ${status}`)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal navigation helper (avoids React-router import at module level)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _navigateToLogin: (() => void) | null = null
|
||||
|
||||
/**
|
||||
* Register a callback that the middleware calls on 401.
|
||||
* SessionProvider calls this during its setup.
|
||||
*/
|
||||
export function registerLoginRedirect(fn: () => void): void {
|
||||
_navigateToLogin = fn
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSRF middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
||||
const LOGIN_PATH = '/api/auth/login'
|
||||
|
||||
const csrfMiddleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
// Always include cookies (same-origin; explicit for clarity)
|
||||
// Note: credentials is set at client level; this is belt-and-suspenders doc.
|
||||
|
||||
const method = request.method.toUpperCase()
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (WRITE_METHODS.has(method) && url.pathname !== LOGIN_PATH) {
|
||||
const token = getCsrfToken()
|
||||
if (token) {
|
||||
request.headers.set('X-CSRF-Token', token)
|
||||
}
|
||||
}
|
||||
|
||||
return request
|
||||
},
|
||||
|
||||
async onResponse({ response }) {
|
||||
if (response.status === 401) {
|
||||
// Clear any cached session state by triggering a page navigation.
|
||||
// The SessionProvider query will refetch and find no session.
|
||||
if (_navigateToLogin) {
|
||||
_navigateToLogin()
|
||||
}
|
||||
// Return the original response so callers can handle 401 if needed.
|
||||
return response
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Parse body and throw; caller can catch ApiError and read .body
|
||||
let body: unknown
|
||||
try {
|
||||
body = await response.clone().json()
|
||||
} catch {
|
||||
body = null
|
||||
}
|
||||
throw new ApiError(response.status, body)
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client instance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const apiClient = createClient<paths>({
|
||||
baseUrl: '/',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
apiClient.use(csrfMiddleware)
|
||||
|
||||
export default apiClient
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Smoke tests for the CSRF token holder.
|
||||
* These run in isolation (no DOM, no React) and validate the module contract.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setCsrfToken, getCsrfToken } from './csrf'
|
||||
|
||||
describe('csrf holder', () => {
|
||||
beforeEach(() => {
|
||||
// Reset to empty between tests by setting empty string
|
||||
setCsrfToken('')
|
||||
})
|
||||
|
||||
it('returns empty string before any token is set', () => {
|
||||
expect(getCsrfToken()).toBe('')
|
||||
})
|
||||
|
||||
it('stores and returns the token that was set', () => {
|
||||
setCsrfToken('test-token-abc123')
|
||||
expect(getCsrfToken()).toBe('test-token-abc123')
|
||||
})
|
||||
|
||||
it('overwrites a previously set token', () => {
|
||||
setCsrfToken('first')
|
||||
setCsrfToken('second')
|
||||
expect(getCsrfToken()).toBe('second')
|
||||
})
|
||||
|
||||
it('can be reset to empty', () => {
|
||||
setCsrfToken('some-token')
|
||||
setCsrfToken('')
|
||||
expect(getCsrfToken()).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Module-level CSRF token holder.
|
||||
*
|
||||
* The token is populated by SessionProvider after a successful GET /api/session.
|
||||
* The fetch client middleware reads it on every non-GET/HEAD request.
|
||||
*
|
||||
* Per the project CSRF contract (m2-frontend-v2.md §3.2, orchestrator-decisions.md §3):
|
||||
* - Server checks presence/non-empty only, does NOT validate the value.
|
||||
* - Sending an empty-string or stale value will result in a 403; callers must
|
||||
* ensure setCsrfToken() is called before issuing write requests.
|
||||
*/
|
||||
|
||||
let _csrfToken = ''
|
||||
|
||||
/** Store the CSRF token returned by GET /api/session. */
|
||||
export function setCsrfToken(token: string): void {
|
||||
_csrfToken = token
|
||||
}
|
||||
|
||||
/** Return the current CSRF token (may be empty string if not yet set). */
|
||||
export function getCsrfToken(): string {
|
||||
return _csrfToken
|
||||
}
|
||||
Vendored
+1651
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* ProtectedRoute — renders children when authenticated; redirects to /login otherwise.
|
||||
*
|
||||
* Shows nothing while the session is still loading to avoid flash-of-login.
|
||||
* T07 can replace the loading placeholder with a proper spinner/skeleton.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useSession } from './SessionProvider'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { status } = useSession()
|
||||
|
||||
if (status === 'loading') {
|
||||
// Render nothing while we check the session — avoids a flash to /login.
|
||||
return null
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* SessionProvider — fetches GET /api/session once on mount via TanStack Query.
|
||||
*
|
||||
* Contract (orchestrator-decisions.md §4, §11):
|
||||
* - 200 → authenticated; calls setCsrfToken(data.csrf_token) so write requests work.
|
||||
* - 401 → unauthenticated (not an error toast; normal state before login).
|
||||
* - Exposes { user, status } to descendants via useSession().
|
||||
*
|
||||
* Also registers the 401 → /login redirect with the API client middleware.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, type ReactNode } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import apiClient, { registerLoginRedirect } from '../api/client'
|
||||
import { setCsrfToken } from '../api/csrf'
|
||||
import type { components } from '../api/schema.d.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SessionUser = components['schemas']['SessionUser']
|
||||
|
||||
type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated'
|
||||
|
||||
interface SessionContextValue {
|
||||
user: SessionUser | null
|
||||
status: SessionStatus
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SessionContext = createContext<SessionContextValue>({
|
||||
user: null,
|
||||
status: 'loading',
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Access the current session from any descendant component. */
|
||||
export function useSession(): SessionContextValue {
|
||||
return useContext(SessionContext)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SessionProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SessionProvider({ children }: SessionProviderProps) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Register the 401 redirect callback with the API client once.
|
||||
useEffect(() => {
|
||||
registerLoginRedirect(() => {
|
||||
// Invalidate the session query so any subscriber re-fetches (→ unauthenticated).
|
||||
queryClient.invalidateQueries({ queryKey: ['session'] })
|
||||
navigate('/login', { replace: true })
|
||||
})
|
||||
}, [navigate, queryClient])
|
||||
|
||||
const { data, status, error } = useQuery({
|
||||
queryKey: ['session'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/session')
|
||||
// openapi-fetch returns { data, error, response }.
|
||||
// On 401 the middleware already navigates; here data will be undefined.
|
||||
return res.data ?? null
|
||||
},
|
||||
// Don't treat 401 as a React Query "error" — it's a normal unauthenticated state.
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
})
|
||||
|
||||
// When we get session data, store the CSRF token.
|
||||
useEffect(() => {
|
||||
if (data?.csrf_token) {
|
||||
setCsrfToken(data.csrf_token)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
let sessionStatus: SessionStatus
|
||||
if (status === 'pending') {
|
||||
sessionStatus = 'loading'
|
||||
} else if (status === 'error' || data === null || !data) {
|
||||
// 401 returns null from our queryFn; any actual network error → unauthenticated.
|
||||
sessionStatus = 'unauthenticated'
|
||||
// Suppress unused variable warning for error in non-401 cases
|
||||
void error
|
||||
} else {
|
||||
sessionStatus = 'authenticated'
|
||||
}
|
||||
|
||||
const value: SessionContextValue = {
|
||||
user: data?.user ?? null,
|
||||
status: sessionStatus,
|
||||
}
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Entry point — mounts the React app into #root.
|
||||
*/
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element #root not found in document')
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* ConfigPage — placeholder for M2-T08.
|
||||
*
|
||||
* T08 replaces this with the real config UI: all config sections rendered as editable
|
||||
* fields, secret masking, save button (PUT /api/config), and SMTP test action.
|
||||
*/
|
||||
|
||||
import { Container, Title, Text } from '@mantine/core'
|
||||
|
||||
export function ConfigPage() {
|
||||
return (
|
||||
<Container pt="xl">
|
||||
<Title order={2}>Configuration</Title>
|
||||
<Text c="dimmed" mt="sm">
|
||||
Config editor — implemented in M2-T08.
|
||||
</Text>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* HomePage — placeholder for M2-T09.
|
||||
*
|
||||
* T09 replaces this with the real home view: Leaflet map, heatmap layer,
|
||||
* time-range selector, scatter-point layer, and poo overlay.
|
||||
*/
|
||||
|
||||
import { Container, Title, Text } from '@mantine/core'
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<Container pt="xl">
|
||||
<Title order={2}>Home</Title>
|
||||
<Text c="dimmed" mt="sm">
|
||||
Map / heatmap visualisation — implemented in M2-T09.
|
||||
</Text>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* LoginPage — placeholder for M2-T07.
|
||||
*
|
||||
* T07 replaces this with the real login form (username/password → POST /api/auth/login,
|
||||
* force-password-change flow, redirect to home on success).
|
||||
*/
|
||||
|
||||
import { Container, Title, Text } from '@mantine/core'
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<Container size="xs" pt="xl">
|
||||
<Title order={2}>Login</Title>
|
||||
<Text c="dimmed" mt="sm">
|
||||
Login form — implemented in M2-T07.
|
||||
</Text>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Vitest global setup file.
|
||||
* Imports @testing-library/jest-dom to extend vitest matchers with DOM assertions.
|
||||
*/
|
||||
import '@testing-library/jest-dom'
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user