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 { 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'
|
2026-06-13 15:20:35 +02:00
|
|
|
import {
|
|
|
|
|
MantineProvider,
|
|
|
|
|
Group,
|
|
|
|
|
ActionIcon,
|
|
|
|
|
Tooltip,
|
|
|
|
|
useMantineColorScheme,
|
|
|
|
|
useComputedColorScheme,
|
|
|
|
|
} from '@mantine/core'
|
|
|
|
|
import { List, Settings, Sun, Moon, LogOut } from 'react-feather'
|
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 (
|
2026-06-13 15:20:35 +02:00
|
|
|
<Tooltip label="Log out">
|
|
|
|
|
<ActionIcon
|
|
|
|
|
variant="default"
|
|
|
|
|
size="lg"
|
|
|
|
|
onClick={handleLogout}
|
|
|
|
|
aria-label="Log out"
|
|
|
|
|
data-testid="logout-button"
|
|
|
|
|
>
|
|
|
|
|
<LogOut size={18} />
|
|
|
|
|
</ActionIcon>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Dark-mode toggle (sits next to the gear / settings icon)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function ColorSchemeToggle() {
|
|
|
|
|
const { setColorScheme } = useMantineColorScheme()
|
|
|
|
|
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
|
|
|
|
const isDark = computed === 'dark'
|
|
|
|
|
return (
|
|
|
|
|
<Tooltip label={isDark ? 'Light mode' : 'Dark mode'}>
|
|
|
|
|
<ActionIcon
|
|
|
|
|
variant="default"
|
|
|
|
|
size="lg"
|
|
|
|
|
aria-label="Toggle color scheme"
|
|
|
|
|
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
|
|
|
|
data-testid="color-scheme-toggle"
|
|
|
|
|
>
|
|
|
|
|
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
|
|
|
|
</ActionIcon>
|
|
|
|
|
</Tooltip>
|
2026-06-13 10:04:14 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
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',
|
2026-06-13 15:20:35 +02:00
|
|
|
borderBottom: '1px solid var(--mantine-color-default-border)',
|
2026-06-13 09:52:56 +02:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<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 */}
|
2026-06-13 15:20:35 +02:00
|
|
|
<Tooltip label="Records">
|
|
|
|
|
<ActionIcon
|
|
|
|
|
component={Link}
|
|
|
|
|
to="/records"
|
|
|
|
|
variant="default"
|
|
|
|
|
size="lg"
|
|
|
|
|
aria-label="Records"
|
|
|
|
|
>
|
|
|
|
|
<List size={18} />
|
|
|
|
|
</ActionIcon>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
{/* Dark-mode toggle — directly beside the settings gear */}
|
|
|
|
|
<ColorSchemeToggle />
|
|
|
|
|
{/* Settings — links to config page (§5#10) */}
|
|
|
|
|
<Tooltip label="Settings">
|
|
|
|
|
<ActionIcon
|
|
|
|
|
component={Link}
|
|
|
|
|
to="/config"
|
|
|
|
|
variant="default"
|
|
|
|
|
size="lg"
|
|
|
|
|
aria-label="Settings"
|
|
|
|
|
>
|
|
|
|
|
<Settings size={18} />
|
|
|
|
|
</ActionIcon>
|
|
|
|
|
</Tooltip>
|
2026-06-13 10:04:14 +02:00
|
|
|
<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 (
|
2026-06-13 15:20:35 +02:00
|
|
|
<MantineProvider defaultColorScheme="auto">
|
2026-06-13 09:52:56 +02:00
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
}
|