2026-06-13 09:52:56 +02:00
|
|
|
/**
|
|
|
|
|
* App — top-level provider stack and route tree.
|
|
|
|
|
*
|
|
|
|
|
* Provider order (outermost first):
|
|
|
|
|
* MantineProvider → QueryClientProvider → BrowserRouter → SessionProvider → routes
|
|
|
|
|
*
|
|
|
|
|
* Route tree:
|
2026-06-13 10:04:14 +02:00
|
|
|
* /login → LoginPage (public)
|
|
|
|
|
* /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate)
|
2026-06-13 09:52:56 +02:00
|
|
|
* / → ProtectedRoute → AppLayout → HomePage (T09)
|
|
|
|
|
* /config → ProtectedRoute → AppLayout → ConfigPage (T08)
|
|
|
|
|
*
|
2026-06-13 10:04:14 +02:00
|
|
|
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
|
2026-06-13 09:52:56 +02:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { MantineProvider } from '@mantine/core'
|
|
|
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
2026-06-13 10:04:14 +02:00
|
|
|
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
|
|
|
|
|
import { Button, Group } from '@mantine/core'
|
2026-06-13 09:52:56 +02:00
|
|
|
|
|
|
|
|
// 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'
|
2026-06-13 10:32:02 +02:00
|
|
|
import { RecordsPage } from './pages/RecordsPage'
|
2026-06-13 10:04:14 +02:00
|
|
|
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
|
|
|
|
import apiClient from './api/client'
|
|
|
|
|
import { useQueryClient } from '@tanstack/react-query'
|
2026-06-13 09:52:56 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-13 10:04:14 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Logout button component (needs navigate + queryClient hooks, so it's a component)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function LogoutButton() {
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
const qc = useQueryClient()
|
|
|
|
|
|
|
|
|
|
async function handleLogout() {
|
|
|
|
|
try {
|
|
|
|
|
await apiClient.POST('/api/auth/logout')
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore errors on logout — we clear the session regardless.
|
|
|
|
|
}
|
|
|
|
|
// Invalidate session so SessionProvider becomes unauthenticated.
|
|
|
|
|
await qc.invalidateQueries({ queryKey: ['session'] })
|
|
|
|
|
navigate('/login', { replace: true })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Button variant="subtle" size="xs" onClick={handleLogout} data-testid="logout-button">
|
|
|
|
|
Log out
|
|
|
|
|
</Button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 09:52:56 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// App shell layout (used by all protected pages)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function AppLayout() {
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
2026-06-13 10:04:14 +02:00
|
|
|
{/* Top nav */}
|
2026-06-13 09:52:56 +02:00
|
|
|
<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>
|
2026-06-13 10:04:14 +02:00
|
|
|
|
|
|
|
|
<Group gap="xs">
|
2026-06-13 10:32:02 +02:00
|
|
|
{/* Records nav link */}
|
|
|
|
|
<Link
|
|
|
|
|
to="/records"
|
|
|
|
|
style={{ textDecoration: 'none', fontSize: '0.9rem' }}
|
|
|
|
|
title="Records"
|
|
|
|
|
>
|
|
|
|
|
Records
|
|
|
|
|
</Link>
|
2026-06-13 10:04:14 +02:00
|
|
|
{/* 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>
|
|
|
|
|
<LogoutButton />
|
|
|
|
|
</Group>
|
2026-06-13 09:52:56 +02:00
|
|
|
</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 />} />
|
|
|
|
|
|
2026-06-13 10:04:14 +02:00
|
|
|
{/* Forced password change — protected (must be logged in) but outside AppLayout */}
|
|
|
|
|
<Route
|
|
|
|
|
path="/change-password"
|
|
|
|
|
element={
|
|
|
|
|
<ProtectedRoute>
|
|
|
|
|
<ChangePasswordPage />
|
|
|
|
|
</ProtectedRoute>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-06-13 09:52:56 +02:00
|
|
|
{/* Protected routes — all nested under AppLayout */}
|
|
|
|
|
<Route
|
|
|
|
|
element={
|
|
|
|
|
<ProtectedRoute>
|
|
|
|
|
<AppLayout />
|
|
|
|
|
</ProtectedRoute>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Route index element={<HomePage />} />
|
|
|
|
|
<Route path="/config" element={<ConfigPage />} />
|
2026-06-13 10:32:02 +02:00
|
|
|
<Route path="/records" element={<RecordsPage />} />
|
2026-06-13 09:52:56 +02:00
|
|
|
</Route>
|
|
|
|
|
</Routes>
|
|
|
|
|
</SessionProvider>
|
|
|
|
|
</BrowserRouter>
|
|
|
|
|
</QueryClientProvider>
|
|
|
|
|
</MantineProvider>
|
|
|
|
|
)
|
|
|
|
|
}
|