M2-T07: build auth UI (login, session bootstrap, forced password change, logout)
- real Mantine login form -> POST /api/auth/login; 401 inline error; redirect when already authed - ProtectedRoute: loading state, preserves intended destination, gates force_password_change - ChangePasswordPage forced-change gate -> POST /api/auth/password - logout control in AppLayout nav -> POST /api/auth/logout - typed client only; vitest tests for the login flow
This commit is contained in:
+58
-14
@@ -5,17 +5,18 @@
|
||||
* MantineProvider → QueryClientProvider → BrowserRouter → SessionProvider → routes
|
||||
*
|
||||
* Route tree:
|
||||
* /login → LoginPage (public, T07 will build the real form)
|
||||
* /login → LoginPage (public)
|
||||
* /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate)
|
||||
* / → 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.
|
||||
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
|
||||
*/
|
||||
|
||||
import { MantineProvider } from '@mantine/core'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter, Routes, Route, Link, Outlet } from 'react-router-dom'
|
||||
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { Button, Group } from '@mantine/core'
|
||||
|
||||
// Mantine requires its CSS to be imported once.
|
||||
import '@mantine/core/styles.css'
|
||||
@@ -25,6 +26,9 @@ import { ProtectedRoute } from './auth/ProtectedRoute'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { ConfigPage } from './pages/ConfigPage'
|
||||
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
||||
import apiClient from './api/client'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TanStack Query client (singleton, created outside render to avoid re-creation)
|
||||
@@ -45,6 +49,32 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App shell layout (used by all protected pages)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -52,7 +82,7 @@ const queryClient = new QueryClient({
|
||||
function AppLayout() {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Minimal top nav — T07–T10 can enhance this with Mantine AppShell */}
|
||||
{/* Top nav */}
|
||||
<nav
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -65,15 +95,19 @@ function AppLayout() {
|
||||
<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>
|
||||
|
||||
<Group gap="xs">
|
||||
{/* 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>
|
||||
</nav>
|
||||
|
||||
{/* Page content */}
|
||||
@@ -98,6 +132,16 @@ export default function App() {
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Forced password change — protected (must be logged in) but outside AppLayout */}
|
||||
<Route
|
||||
path="/change-password"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ChangePasswordPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes — all nested under AppLayout */}
|
||||
<Route
|
||||
element={
|
||||
|
||||
Reference in New Issue
Block a user